Self-hosted · .NET 10 · SQL Server

An in-house API gateway for WhatsApp messaging

WApi is a messaging gateway that offers the same surface as the WhatsApp Business API, but runs on your own server. It sends messages over REST, forwards incoming messages and status updates to your application via Webhook, and publishes a live event stream over SignalR. There is no SaaS subscription, no per-message pricing and no closed black box — the source code is yours, the data stays in your SQL Server, and the authentication keys live only with you.

Framework
.NET 10
Database
SQL Server 2019+
Authentication
X-API-Key + SHA-256
Engine
Baileys (Multi-Device)

Why WApi?

Any environment that speaks a standard HTTP library can be integrated with WApi within minutes. The capabilities below are enabled in the out-of-the-box setup; they require no extra license, extra fee or extra module.

REST + JSON, Standard HTTP

All endpoints live under /api, with JSON request/response bodies and standard status codes. Works with C#, VB.NET, Java, PHP, Python, Node, Go, even curl. You can explore the endpoints without writing code via Swagger UI and try them out instantly with Try-it-out.

HMAC-Signed Webhook

Incoming messages, message status changes (sent/delivered/read) and session events (qr, ready, disconnected) are POSTed to the URL you provide. Every request carries the HMAC-SHA256 signature of the raw body in the X-WApi-Signature header. Failed delivery attempts are retried a configurable number of times.

SignalR Live Event Stream

Through the /api/events hub, your browser or .NET clients subscribe to a session and watch events such as QR display, message flow and status changes in real time. No polling required.

Bulk Campaigns, with Throttling

Send to hundreds of recipients in a single request with the send-bulk endpoint. Custom text can be given for each recipient, and the sending interval (intervalMs) can be set in milliseconds. Track the sent, failed and status counters from the batch summary while it runs.

Role-Based API Keys

Three role levels — Admin (everything), Operator (message + webhook management), Viewer (read-only). Optional CIDR allowlist and per-session restriction for each key. Keys are stored in the database as SHA-256 hashes; the raw value is shown only once, at creation time.

SQL Server Native, No EF Core

The data layer runs on a thin SqlHelper built on Microsoft.Data.SqlClient — there is no ORM. Tables are created at boot time with idempotent CREATE TABLE. With stored procedures, table-valued parameters and hand-tuned queries you can optimize wherever you like.

Multi-Session, Multi-Number

Every WhatsApp account is a session. Manage multiple numbers from a single WApi installation. Each session has its own message history, webhook configuration, status information and auth state.

Audit Log, Fully Traceable

Every API call — who, with which role, from which IP, what they did, with what result — is written to the dbo.audit_logs table. It can be filtered through the GET /api/audit endpoint.

Architecture and How It Works

WApi has a layered design. Each layer has a single responsibility and the layers talk to each other through lean interfaces. The diagram below shows the end-to-end path of a client request.

Client C# / VB.NET / Java / curl / SDK
HTTPS + X-API-Key
→
Caddy / Nginx TLS termination
Forwarded headers
↓
WApi.Api (Kestrel) Controllers · ApiKeyAuthHandler · Swagger
SignalR Hub · Webhook Dispatcher
↓
WApi.Infrastructure SqlHelper · Repositories · Engine selector
↓ ↓
SQL Server sessions · messages · webhooks
api_keys · audit_logs
IWhatsAppEngine null (stub) · node-bridge (Baileys)
↓ ↓

Layer Responsibilities

WApi.Domain
Pure POCOs, enums, the IWhatsAppEngine interface and engine models. No dependencies.
WApi.Application
DTO records (SendTextRequest, WebhookDto, etc.) and Entity ↔ DTO mappers. Validation attributes live here.
WApi.Infrastructure
SqlHelper (ADO.NET wrapper), per-entity repositories, ApiKeyAuthHandler, WebhookDispatcher, and the registration of the active IWhatsAppEngine implementation.
WApi.Api
ASP.NET Core Controllers, Swagger pipeline, SignalR hub, this page via UseStaticFiles, Forwarded Headers, Windows Service hosting.

Engine Layer

The WhatsApp protocol is abstracted behind IWhatsAppEngine. Through configuration (Engine:Type) you choose between the following implementations:

null default

An in-process stub. It gives fake responses to all engine calls, sent messages are written to dbo.messages and synchronous fake message ids with a NULL- prefix are returned. It does not connect to the real WhatsApp network. Ideal for development, integration testing and demos.

node-bridge production

WApi's C# side forwards commands over HTTP to a small Node.js sidecar process running on 127.0.0.1 on the same server. The sidecar connects to the real WhatsApp Multi-Device protocol using the @whiskeysockets/baileys library. The sidecar is protected by a shared bearer token (X-Bridge-Token) and does not listen outside loopback.

Request Lifecycle

  1. The client sends an HTTPS request with the X-API-Key header.
  2. Caddy terminates TLS, adds the X-Forwarded-For and -Proto headers, and forwards the request to 127.0.0.1:2785.
  3. Kestrel receives the request; the real client IP is recovered with UseForwardedHeaders.
  4. ApiKeyAuthHandler trims the header, SHA-256 hashes it and looks for a match in dbo.api_keys. It checks whether the key is active, not expired and allowed by the CIDR allowlist. It builds a ClaimsPrincipal and hands it to the pipeline.
  5. Authorization Policy verifies whether the role is authorized to call the endpoint (AdminOnly / OperatorOrAdmin / AnyRole).
  6. Controller runs validation, calls IWhatsAppEngine, writes the result to dbo.messages and returns to the client.
  7. WebhookDispatcher (if any) delivers the relevant event to that session's active webhooks with an HMAC-SHA256 signed POST.
  8. SignalR hub broadcasts the same event to all live clients subscribed to that session.

Integration Guide

Getting an application to talk to WApi end to end takes three steps: get an API key, open a session, send a message. The examples below show each step of a typical integration using the placeholder YOUR_API_KEY variable.

  1. 1 · Create an API key

    On first setup, the Security:MasterKey value in appsettings.json is your first Admin key; you create subsequent keys with it. For day-to-day operations, it is recommended to use work keys with the appropriate role (Operator/Viewer) instead of the Master key.

    curl -X POST https://wapi.erp.tr/api/auth/api-keys \
         -H "X-API-Key: $MASTER_KEY" \
         -H "Content-Type: application/json" \
         -d '{ "name": "billing-app-prod", "role": 1, "allowedIps": "203.0.113.0/24" }'
    
    # Response: the created apiKey is returned raw **only once**. Store it.
    # { "dto": { ... }, "apiKey": "wapi_..." }

    Roles: 0 = Viewer, 1 = Operator, 2 = Admin.

  2. 2 · Open a session and link the phone

    Each WhatsApp number is kept as a separate session. With the node-bridge engine, a QR code is generated when the session starts; the phone pairs by scanning this QR from the WhatsApp app via Settings → Linked Devices → Link a Device. After pairing, the auth state is stored on disk; no rescan is needed even if the service restarts.

    # Create session
    curl -X POST https://wapi.erp.tr/api/sessions \
         -H "X-API-Key: $YOUR_API_KEY" -H "Content-Type: application/json" \
         -d '{ "name": "sales-department", "proxyUrl": null, "proxyType": null }'
    
    # Store the id from the response; every endpoint from here on takes that id.
    SESSION_ID=...
    
    # Start the session (brings up the engine; generates the QR)
    curl -X POST https://wapi.erp.tr/api/sessions/$SESSION_ID/start \
         -H "X-API-Key: $YOUR_API_KEY"
    
    # Fetch the QR — returned as a base64 PNG (can be used directly as an img src)
    curl https://wapi.erp.tr/api/sessions/$SESSION_ID/qr \
         -H "X-API-Key: $YOUR_API_KEY"
    # { "sessionId": "...", "qrCode": "data:image/png;base64,iVBORw...", "status": 0 }
  3. 3 · Send a message

    Once the session moves to the connected state, any message endpoint can be called. Below is a minimal integration close to real production code in each language.

    // .NET 8+ — minimal client with HttpClient
    using System.Net.Http.Headers;
    using System.Net.Http.Json;
    
    var http = new HttpClient { BaseAddress = new Uri("https://wapi.erp.tr") };
    http.DefaultRequestHeaders.Add("X-API-Key", "YOUR_API_KEY");
    
    // Send message
    var payload = new { chatId = "905321234567@s.whatsapp.net", text = "Hello!" };
    var res = await http.PostAsJsonAsync($"/api/sessions/{sessionId}/messages/send-text", payload);
    res.EnsureSuccessStatusCode();
    var msg = await res.Content.ReadFromJsonAsync<MessageDto>();
    Console.WriteLine($"WApi message id: {msg!.Id}  WhatsApp id: {msg.WaMessageId}");
    
    // Error check
    if (msg.Status == MessageStatus.Failed)
        throw new Exception("The message was not accepted by the engine.");
    
    public record MessageDto(Guid Id, Guid SessionId, string? WaMessageId,
        string ChatId, string? Body, int Type, int Direction, int Status,
        long Timestamp, DateTime CreatedAt);
    
    public enum MessageStatus { Pending = 0, Sent = 1, Delivered = 2, Read = 3, Failed = 4 }
    

Authentication and Authorization

WApi uses a single authentication mechanism: a key in the HTTP header. Every detail of key registration and validation is explained below.

Key format and transmission

Every API key is generated as the wapi_ prefix + 64 hex characters (32 random bytes). All requests carry the key in the X-API-Key header:

GET /api/sessions HTTP/1.1
Host: wapi.erp.tr
X-API-Key: wapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json

Storage

Only the SHA-256 hash + the first 8 characters (prefix, for display) are kept in the database. The raw key is never stored. It is returned once, in the response of POST /api/auth/api-keys, at the moment the key is created; if lost, a new one must be created and the old one revoked (POST /api/auth/api-keys/{id}/revoke).

Roles

RoleValueAccess
Viewer 0All read endpoints: GET sessions, messages, groups, contacts, stats, audit (within their own permissions).
Operator 1Viewer + start/stop sessions, send messages (send-text/-image/-video/-audio/-document/-sticker/-location), bulk campaigns, add/update/delete webhooks.
Admin 2Operator + API key management, audit log, infrastructure endpoints.

Optional restrictions

  • CIDR allowlist — comma-separated CIDRs are written to the AllowedIps field (e.g. "203.0.113.0/24,198.51.100.42"). A call from any other IP gets 401.
  • Session restriction — comma-separated session GUIDs are written to the AllowedSessions field (e.g. "7f3c…,9b12…"); the key can access only those sessions, an out-of-scope session gets 403 and never appears in list endpoints. For multi-tenant scenarios.
  • Expiry — ExpiresAt UTC date; after it, the key automatically returns 401.

Key management endpoints (Admin)

  • GET /api/auth/api-keys — List all keys (prefix + metadata; the raw key is not returned).
  • POST /api/auth/api-keys — Create a new key (the raw key is returned once in the response body).
  • PUT /api/auth/api-keys/{id} — Update role, allowlist, active state, expiry.
  • POST /api/auth/api-keys/{id}/revoke — Revoke the key.
  • DELETE /api/auth/api-keys/{id} — Permanently delete the key.

API Reference

All endpoints are under /api and (except those marked anonymous) require X-API-Key. All schemas and sample requests/responses are available in Swagger UI: /api/docs.

Health & Identity

MethodPathRoleDescription
GET /api/health anonymous Health + DB connection check
GET /api/health/live anonymous Liveness probe (is the process healthy?)
GET /api/health/ready anonymous Readiness probe (is the DB reachable?)
POST /api/auth/validate anonymous Returns the validity, name and role of the given key

Sessions

MethodPathRoleDescription
GET /api/sessions Viewer+ List sessions
POST /api/sessions Operator Create a new session
GET /api/sessions/{id} Viewer+ Session details
DELETE /api/sessions/{id} Operator Delete the session (including auth state)
POST /api/sessions/{id}/start Operator Bring up the engine (generates QR)
POST /api/sessions/{id}/stop Operator Stop the engine
GET /api/sessions/{id}/qr Operator QR code (base64 PNG)
GET /api/sessions/{id}/groups Viewer+ Groups it has joined
GET /api/sessions/{id}/contacts Viewer+ Contact list

Messages

MethodPathRoleDescription
GET /api/sessions/{id}/messages Viewer+ Message history (skip / take)
POST /api/sessions/{id}/messages/send-text Operator Text
POST /api/sessions/{id}/messages/send-image Operator Image (with caption)
POST /api/sessions/{id}/messages/send-video Operator Video
POST /api/sessions/{id}/messages/send-audio Operator Audio
POST /api/sessions/{id}/messages/send-document Operator Document
POST /api/sessions/{id}/messages/send-sticker Operator Sticker
POST /api/sessions/{id}/messages/send-location Operator Location (lat/lng/label)
POST /api/sessions/{id}/messages/send-bulk Operator Bulk campaign
POST /api/sessions/{id}/messages/react Operator Emoji reaction to a message
POST /api/sessions/{id}/messages/delete Operator Delete a message
GET /api/sessions/{id}/messages/batch/{batchId} Viewer+ Batch status
POST /api/sessions/{id}/messages/batch/{batchId}/cancelOperator Cancel batch

Webhooks, Statistics, Audit

MethodPathRoleDescription
GET /api/sessions/{id}/webhooks Viewer+ List webhooks
POST /api/sessions/{id}/webhooks Operator Add webhook
PUT /api/sessions/{id}/webhooks/{wid} Operator Update
DELETE /api/sessions/{id}/webhooks/{wid} Operator Delete
POST /api/sessions/{id}/webhooks/{wid}/test Operator Send a test request
GET /api/stats/overview Viewer+ Total message/session counts
GET /api/stats/messages Viewer+ Time-based message statistics
GET /api/stats/sessions/{id} Viewer+ Per-session statistics
GET /api/audit Admin Audit log (who, what, when)
GET /api/infra Admin Infra/engine status
WS /api/events Viewer+ SignalR live event hub

Webhook Integration

When you add a webhook, WApi forwards the events that occur on that session to the URL you provide as an HTTP POST. The following event types are published by default.

Event types

EventTriggered whenTypical payload
message.in A new incoming message is received { chatId, from, body, type, timestamp, waMessageId }
message.out An outgoing message is handed to the engine { chatId, body, type, waMessageId }
message.ack The message status changes (sent/delivered/read) { waMessageId, status }
session.qr A new QR is generated { qrCode }
session.status The session status changes { status, phone, pushName }

Request format

Every webhook call arrives in the following structure:

POST /your/webhook/path HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
X-WApi-Event: message.in
X-WApi-Session: 3b358e5f-5fc4-4198-8457-c1983baad23a
X-WApi-Timestamp: 1779186278776
X-WApi-Signature: sha256=<hex>
User-Agent: WApi/1.1 (+https://wapi.erp.tr)

{
  "event": "message.in",
  "sessionId": "3b358e5f-5fc4-4198-8457-c1983baad23a",
  "at": "2026-05-19T10:24:38.7757623Z",
  "payload": {
    "chatId": "905321234567@s.whatsapp.net",
    "from": "905321234567@s.whatsapp.net",
    "body": "Hello!",
    "type": 0,
    "timestamp": 1779186278776,
    "waMessageId": "3EB09535AA6578CE1FC528"
  }
}

Signature verification

The signature is in the X-WApi-Signature header in the sha256=<hex> format. Computation: HMAC-SHA256( secret = webhook.Secret, message = raw_request_body ). Verification example:

// ASP.NET Core (example)
[HttpPost("wapi/incoming")]
public async Task<IActionResult> Incoming()
{
    using var reader = new StreamReader(Request.Body);
    var raw = await reader.ReadToEndAsync();

    var sig = Request.Headers["X-WApi-Signature"].ToString();
    if (!sig.StartsWith("sha256=")) return Unauthorized();

    var expected = Convert.ToHexString(
        new HMACSHA256(Encoding.UTF8.GetBytes("shared-hmac-secret"))
            .ComputeHash(Encoding.UTF8.GetBytes(raw))
    ).ToLowerInvariant();

    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.ASCII.GetBytes(sig[7..]),
            Encoding.ASCII.GetBytes(expected)))
        return Unauthorized();

    var evt = JsonSerializer.Deserialize<WebhookEnvelope>(raw);
    // ... process
    return Ok();
}

Retries and idempotency

  • Responses other than 2xx are considered failures. WApi retries up to retryCount (default 3) times with exponential backoff (1s, 2s, 4s, 8s…).
  • To guard against the same event arriving more than once, endpoints that behave idempotently using the X-WApi-Timestamp + waMessageId pair are recommended.
  • The maximum processing time on the webhook side is limited by Webhook:TimeoutSeconds (default 10 seconds). Queue long-running work and return 200 immediately.

Error Handling

WApi uses standard HTTP status codes and returns a body structured as RFC 9457 — Problem Details for errors.

CodeMeaningTypical causeBehavior
200 / 201 / 204Success——
400Bad requestBody that is missing fields or has type errors (e.g. empty chatId)Fix and resend; inspect the errors field in the body.
401UnauthenticatedMissing/invalid key, request from outside the IP allowlist, expired keyAdd the correct key header, add the IP to the allowlist.
403ForbiddenThe key's role is not sufficient to call the endpoint (e.g. send-text with Viewer)Use a key with a higher role or update the role.
404Not foundSession/Message/Webhook id does not existVerify the id.
409ConflictSession is not connected yet; a session with the same name existsFirst start + scan the QR; or choose a different name.
429Rate limitExcessive sending (global throttling applied on top)Wait for the Retry-After header duration and retry.
500 / 502 / 503Server errorEngine connection loss, DB access errorRetry with exponential backoff; if persistent, check /api/health and /api/infra.

Recommended client behavior

  • Idempotency key: to prevent the same message from being sent twice, keep a unique id on the client side and store message ids in your DB.
  • Exponential backoff: for 429/5xx, at most 5 attempts at increasing intervals such as 1s, 2s, 4s, 8s, 16s. For bulk campaigns, increase intervalMs.
  • Circuit breaker: if more than 50% of responses are 5xx within 5 minutes, temporarily pause sending from the client; wait until /api/health returns 200.
  • Logging: record the traceId field (inside Problem Details) in your own log — it matches the server logs one-to-one.

Setup and Deployment

WApi can be brought into service within hours on a typical Windows or Linux server. Below is the reference configuration for a Windows + Caddy + SQL Server setup.

Prerequisites

  • .NET 10 SDK (for building) + .NET 10 ASP.NET Core Runtime (for running)
  • SQL Server 2019+ (LocalDB, on-prem, Docker or Azure SQL)
  • Reverse proxy (recommended: Caddy, for automatic TLS)
  • Node.js 22 LTS for real WhatsApp sending (for the sidecar; adding it to the system PATH is not required)

1 · Publish the API as a Windows Service

# 1. Build + publish (framework-dependent; the .NET 10 runtime must be on the server)
dotnet publish src/WApi.Api -c Release -o C:\.MyApps\Wapi\Api

# 2. Write the production appsettings next to the binaries (ConnectionStrings, Security:MasterKey, ...)
#    Lock the file down with restricted access:
icacls C:\.MyApps\Wapi\Api\appsettings.Production.json /inheritance:r `
    /grant:r "NT AUTHORITY\SYSTEM:R" "BUILTIN\Administrators:F"

# 3. Create the service
sc.exe create WApi.Api binPath= "\"C:\.MyApps\Wapi\Api\WApi.Api.exe\" `
    --contentRoot \"C:\.MyApps\Wapi\Api\" --urls \"http://127.0.0.1:2785\"" `
    start= auto obj= LocalSystem DisplayName= "WApi — WhatsApp API Gateway"

# 4. Set the production environment as a registry env-var
New-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Services\WApi.Api `
    -Name Environment -PropertyType MultiString `
    -Value @("ASPNETCORE_ENVIRONMENT=Production","DOTNET_ENVIRONMENT=Production") -Force

Start-Service WApi.Api

2 · TLS and reverse proxy with Caddy

wapi.example.com {
    encode zstd gzip
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
    }

    # For SignalR long-lived connections
    @signalr path /api/events*
    handle @signalr {
        reverse_proxy 127.0.0.1:2785 {
            flush_interval -1
            stream_close_delay 5m
            transport http { keepalive 60s }
        }
    }

    reverse_proxy 127.0.0.1:2785
}

3 · node-bridge sidecar for real WhatsApp

In a production setup, the nodebridge/ folder runs as a separate Windows Service (e.g. with WinSW), brings up Baileys and listens on loopback at 127.0.0.1:2787. WApi.Api reaches it with a shared WAPI_BRIDGE_TOKEN bearer. appsettings.Production.json:

{
  "Engine": { "Type": "node-bridge" },
  "NodeBridge": {
    "BaseUrl": "http://127.0.0.1:2787",
    "Token": "",
    "TimeoutSeconds": 60
  }
}

The sidecar keeps a separate auth state folder for each session using Baileys' useMultiFileAuthState mechanism. When the service restarts, the credentials are loaded from this folder; no rescan is needed.

4 · Whole stack in one command with Docker

To bring up SQL Server + API + Dashboard with a single compose file:

docker compose up --build
# API:        http://localhost:2785/api/docs
# Dashboard:  http://localhost:2886

Frequently Asked Questions

Is WApi an official WhatsApp Business solution?

No. WApi is an open-source gateway that connects as a client to the WhatsApp Multi-Device protocol (the whatsapp-web.js / Baileys family). It is not on the same surface as the official WhatsApp Business API; it should not be confused with the official REST API published by Meta. Under the official ToS, bulk/spam sending can get an account blocked; keep your usage pattern close to a personal communication profile.

Do I run it as SaaS or on my own server?

WApi is run self-hosted. All data (messages, contacts, keys, audit log) stays in your SQL Server. There is no third-party cloud dependency. This lets you fully control the personal data flow under GDPR/KVKK.

Can I manage multiple numbers in a single WApi installation?

Yes. Each number is a session. An unlimited number of sessions can be held with a single WApi.Api and a single Node.js sidecar. Each session gets its own Baileys auth state, message history and webhook configuration.

Are the messages end-to-end encrypted?

The WhatsApp Multi-Device protocol uses Signal Double Ratchet end-to-end encryption; this end-to-end encryption is applied in a protocol layer that WApi does not see. The raw message text WApi sees (what you write or what Baileys decrypts) is of course written to SQL Server in plain form — if needed, use SQL Server TDE or column-level encryption.

Will my WhatsApp get banned?

WhatsApp acts protectively against non-Business clients. Excessive/sequential/identical message sending triggers automatic safeguards. Low volume and personalized content generally cause no problems; for bulk campaigns, keeping intervalMs above 1500ms, varying at least one word for each recipient and implementing "opt-out" mechanics reduces the risk. Still, all risk rests with the user.

How do I back up?

Two components are backed up: the SQL Server database (standard BACKUP DATABASE) and the auth/ folder in the Node sidecar (session credentials). If these two are backed up together, the system can be moved to another machine as-is — no rescan required.

Is a license required for production use?

No. WApi is under the MIT license. Embedding it in a commercial product, doing a closed distribution, forking it is entirely free. There is no attribution requirement.

Ready? Start with Swagger

Try all endpoints and schemas live with your own key.