Central API documentation
Everything a connected product needs to send WhatsApp messages, start Stripe billing flows, and receive signed events back.
Overview
This API is consumed by trusted first-party applications, not by end users. There are no bearer tokens or OAuth flows: each connected application is issued two secrets and signs every request it makes.
- All endpoints live under https://api.buildwithabdallah.com.
- Requests and responses are JSON. Send Content-Type: application/json.
- Product-facing routes are versioned under /api/v1.
- Errors always return {"error": {"code": "...", "message": "..."}}.
Two secrets per application. The request secret signs calls you make into this API. The event secret verifies events this API sends to your webhook. They are rotated independently and must never be swapped.
Authentication
Every request to /api/v1/* must carry these four headers. A missing or malformed header is rejected with 401 UNAUTHENTICATED before any routing happens.
| Header | Value | Rule |
|---|---|---|
| X-BWA-App | Your application slug, e.g. kirada | Must exist and be enabled. |
| X-BWA-Timestamp | Unix timestamp in seconds | Must be within 300 seconds of server time, in either direction. |
| X-BWA-Request-ID | A fresh UUID or ULID | Single use. Reusing one returns 409 REPLAYED_REQUEST. |
| X-BWA-Signature | sha256=<hex> | HMAC-SHA256 of the canonical string, keyed with your request secret. |
The canonical string
Build the signed payload by joining five fields with newlines. The body hash must be taken over the exact bytes you transmit — re-serialising the payload after signing will invalidate the signature.
HTTP_METHOD REQUEST_PATH TIMESTAMP REQUEST_ID SHA256_HEX_OF_RAW_BODY
Signatures are compared in constant time. For requests without a body, hash the empty string.
Signing a request
A complete, working example in three environments. Store the request secret in your application's secret store — never in source control.
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
$path = '/api/v1/whatsapp/messages';
$body = json_encode($payload, JSON_THROW_ON_ERROR);
$timestamp = (string) now()->timestamp;
$requestId = (string) Str::uuid();
$canonical = implode("\n", [
'POST',
$path,
$timestamp,
$requestId,
hash('sha256', $body),
]);
$signature = 'sha256=' . hash_hmac('sha256', $canonical, config('services.bwa.request_secret'));
// Send the same bytes that were hashed above.
$message = Http::withHeaders([
'X-BWA-App' => 'kirada',
'X-BWA-Timestamp' => $timestamp,
'X-BWA-Request-ID' => $requestId,
'X-BWA-Signature' => $signature,
])->withBody($body, 'application/json')
->post('https://api.buildwithabdallah.com' . $path)
->throw()
->json();
import { createHash, createHmac, randomUUID } from 'node:crypto';
const path = '/api/v1/whatsapp/messages';
const body = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000).toString();
const requestId = randomUUID();
const canonical = [
'POST',
path,
timestamp,
requestId,
createHash('sha256').update(body).digest('hex'),
].join('\n');
const signature = 'sha256=' + createHmac('sha256', process.env.BWA_REQUEST_SECRET)
.update(canonical)
.digest('hex');
// Send the same bytes that were hashed above.
const response = await fetch('https://api.buildwithabdallah.com' + path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-BWA-App': 'kirada',
'X-BWA-Timestamp': timestamp,
'X-BWA-Request-ID': requestId,
'X-BWA-Signature': signature,
},
body,
});
# Requires openssl and uuidgen.
ENDPOINT="/api/v1/whatsapp/messages"
BODY='{"recipient":"+12070000000","type":"template","product":"kirada","template":{"name":"order_update","language":"en_US"},"idempotency_key":"order-1042-shipped"}'
TS=$(date +%s)
RID=$(uuidgen)
HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
CANONICAL=$(printf 'POST\n%s\n%s\n%s\n%s' "$ENDPOINT" "$TS" "$RID" "$HASH")
SIG=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$BWA_REQUEST_SECRET" -hex | awk '{print $NF}')
curl -sS "https://api.buildwithabdallah.com$ENDPOINT" \
-H "Content-Type: application/json" \
-H "X-BWA-App: kirada" \
-H "X-BWA-Timestamp: $TS" \
-H "X-BWA-Request-ID: $RID" \
-H "X-BWA-Signature: sha256=$SIG" \
-d "$BODY"
Messaging endpoints
Outbound sends are queued, not synchronous. A successful call returns 202 Accepted with an internal message ID; the final delivery state arrives later as an event on your webhook.
/api/v1/whatsapp/messages
202
Queue a WhatsApp text or template message.
| Field | Type | Notes |
|---|---|---|
| recipient | string, required | E.164, 7–15 digits. A leading + is optional. |
| type | string, required | text or template. |
| body | string | Required when type is text. Max 4096 characters. |
| template.name | string | Required when type is template. Max 512 characters. |
| template.language | string | Required with a template, e.g. en_US. |
| template.components | array | Optional. Passed through to the provider unchanged. |
| product | string, required | Must match your application slug unless you are explicitly allowed to send for others. |
| correlation_id | string, nullable | Your own reference. Echoed back on every event. |
| idempotency_key | string, required | Stable per business action. See idempotency. |
{
"recipient": "+12070000000",
"type": "template",
"product": "kirada",
"template": {
"name": "order_update",
"language": "en_US",
"components": []
},
"correlation_id": "order-1042",
"idempotency_key": "order-1042-shipped"
}
{
"data": {
"id": "01JQ2R8N4C6R3F0M9YB7K1WZ5X",
"status": "queued",
"correlation_id": "order-1042",
"idempotency_key": "order-1042-shipped",
"direction": "outbound",
"type": "template",
"provider": "meta",
"provider_message_id": null,
"created_at": "2026-08-14T09:31:07+00:00"
}
}
/api/v1/whatsapp/messages/{message}
200
Fetch the current state of a message by its internal ID. Returns the same shape as the send response, with status advanced to accepted, sent, delivered, read or failed.
/api/v1/whatsapp/conversations/{conversation}
200
Fetch a conversation, including whether its customer-service window is still open.
{
"data": {
"id": "01JQ2R8N4C6R3F0M9YB7K1WZ5X",
"contact_id": "01JQ2R8MZZ8Q1V4T7X2A0B9C6D",
"product": "kirada",
"state": "active",
"customer_service_window_expires_at": "2026-08-15T09:12:44+00:00",
"last_incoming_message_at": "2026-08-14T09:12:44+00:00",
"last_outgoing_message_at": "2026-08-14T09:31:07+00:00"
}
}
/api/v1/whatsapp/conversations/{conversation}/route
200
Reassign a conversation to a product. Accepts one field, product, which must be one of kirada, djib-payroll, smkit, custom-software or general-support.
Billing endpoints
Stripe credentials stay in this service. Products ask for a session URL and redirect the customer to it; subscription lifecycle updates arrive later as billing.stripe.* events.
Redirect URLs are allow-listed. Every URL you pass must resolve to your application's registered webhook host, or to a host explicitly configured for your application. Anything else is rejected as a validation error.
/api/v1/billing/checkout-sessions
201
Create a Stripe Checkout session for a subscription plan.
| Field | Type | Notes |
|---|---|---|
| external_customer_id | string, required | Your own customer identifier. Mapped to a Stripe customer here. |
| customer.email | email, required | — |
| customer.name | string, required | — |
| plan.id / plan.name | string, required | Your plan reference and its display name. |
| plan.amount | integer, required | Minor units. Between 50 and 99,999,999. |
| plan.currency | string, required | Three letters, e.g. usd. |
| plan.interval | string, required | month or year. |
| success_url / cancel_url | https URL, required | Must be on an allow-listed host. Max 2048 characters. |
| idempotency_key | string, required | Stable per checkout attempt. |
/api/v1/billing/portal-sessions
201
Create a Stripe billing portal session so a customer can manage their own subscription. Requires external_customer_id, an https return_url on an allow-listed host, and an idempotency_key.
Idempotency & the customer-service window
Idempotency
Every write endpoint requires an idempotency_key. Keys are scoped to your application, so they only need to be unique within your own system. Use a stable key per business action — not a random value per attempt, which would defeat the purpose.
- Same key, same payload — returns the original record. Safe to retry.
- Same key, different payload — returns 409 IDEMPOTENCY_CONFLICT. This means a bug on the caller's side, and is deliberately loud.
- New key — creates a new record.
Note that the idempotency key is separate from X-BWA-Request-ID. The request ID must be unique on every HTTP call, including retries, because it is the replay defence. The idempotency key must be stable across retries, because it is the deduplication key.
The 24-hour customer-service window
WhatsApp only allows free-form messages within 24 hours of the customer's last inbound message. This API enforces that before anything reaches the provider.
- type: "text" requires an open window, otherwise 422 CUSTOMER_SERVICE_WINDOW_EXPIRED.
- type: "template" is always allowed.
- Check customer_service_window_expires_at on the conversation before choosing which to send.
Error codes
Errors use a consistent envelope. Handle the code, not the message text — messages may be reworded.
{
"error": {
"code": "CUSTOMER_SERVICE_WINDOW_EXPIRED",
"message": "An approved WhatsApp template is required outside the customer-service window."
}
}
| Code | HTTP | What to do |
|---|---|---|
| UNAUTHENTICATED | 401 | Check the four headers, your clock drift, and that you signed the exact bytes sent. |
| APPLICATION_DISABLED | 403 | The application has been switched off. Do not retry. |
| PRODUCT_NOT_AUTHORIZED | 403 | The product field does not match your application. |
| REPLAYED_REQUEST | 409 | Generate a fresh X-BWA-Request-ID for every attempt. |
| IDEMPOTENCY_CONFLICT | 409 | The key was already used with different content. Fix the caller. |
| CUSTOMER_SERVICE_WINDOW_EXPIRED | 422 | Send an approved template instead of free-form text. |
| RATE_LIMITED | 429 | You exceeded 120 requests per minute. Back off and retry. |
Receiving events
Message status changes and Stripe lifecycle updates are pushed to your registered webhook URL as signed events. Your endpoint must verify them with your event secret, using the same canonical string as above.
Receiver contract
- Respond 200 or 202 to acknowledge. Anything else is treated as a failure.
- A 5xx or 429 is retried with backoff, up to five attempts. Other statuses are recorded as permanent failures.
- Verify against the raw request body, and reject stale timestamps and reused request IDs exactly as this API does.
- Persist event_id before processing, and ignore an ID you have already seen — retries are expected.
- Phone numbers are not included by default. Events carry internal contact and message IDs.
{
"id": "01JQ2RB4M7X5E2N8P0T3V6Y9QK",
"event_id": "01JQ2RB4M7X5E2N8P0T3V6Y9QK",
"type": "whatsapp.message.status",
"event_type": "whatsapp.message.status",
"occurred_at": "2026-08-14T09:31:12+00:00",
"product": "kirada",
"data": {
"message_id": "01JQ2R8N4C6R3F0M9YB7K1WZ5X",
"provider": "meta",
"provider_message_id": "wamid.HBgLM...",
"correlation_id": "order-1042",
"idempotency_key": "order-1042-shipped",
"status": "delivered",
"occurred_at": "2026-08-14T09:31:12+00:00",
"error": null
}
}
Billing events follow the same envelope with a billing.stripe.<stripe event type> type, and a data object carrying normalised Stripe fields such as stripe_subscription_id, status, currency and current_period_end.
Failed sends carry a reason. When status is failed, the error object contains the provider's code and message so you can distinguish a bad number from an outage.
Health checks
Two unauthenticated endpoints, intended for monitoring.
/up
200
Liveness only. Confirms the application boots and can serve a request.
/health/ready
200 / 503
Readiness. Verifies the database connection, the cache store, the queue configuration and that the active WhatsApp provider has complete credentials. Returns 503 when any check fails, so a load balancer can drain the node.
{
"status": "ready",
"checks": {
"database": "ok",
"cache": "ok",
"queue": "ok",
"whatsapp_provider": "ok"
},
"whatsapp_provider": "meta"
}
Need an application provisioned?
Connected applications are issued from the console, with both secrets shown once.