API reference · v1

Central API documentation

Everything a connected product needs to send WhatsApp messages, start Stripe billing flows, and receive signed events back.

Base URL: api.buildwithabdallah.com OpenAPI 3.1

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.

canonical
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();

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.

POST /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"
}
GET /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.

GET /api/v1/whatsapp/conversations/{conversation} 200

Fetch a conversation, including whether its customer-service window is still open.

Response
{
  "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"
  }
}
POST /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.

POST /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.
POST /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 envelope
{
  "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.
whatsapp.message.status
{
  "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.

GET /up 200

Liveness only. Confirms the application boots and can serve a request.

GET /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.

Response
{
  "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.