WhatsApp API and Webhooks Developer Guide | Masar

Send WhatsApp templates from your own system.

Use the REST API to send messages and manage data from your system. The API applies the same quotas, management fees, and duplicate protection as the dashboard.

Base URL: https://router.atyaf.co/api/v1

Authentication

Send your API key as a bearer token on every request. The key identifies the account, so do not send an account id. A resource belonging to another account returns 404.

curl https://router.atyaf.co/api/v1/me \
  -H "Authorization: Bearer msr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

A production key starts with msr_live_, followed by 48 characters and a 6-character checksum. Copy it in full when it is created.

The key is shown once when it is created and cannot be recovered later. If you lose it, revoke it and create a new key.

A key may be restricted to an address allowlist and given an expiry date. Both are refused at authentication, before anything is charged.

Scopes

Every endpoint requires one scope. Grant a key only what the integration needs. A reporting key without messages:send cannot send or spend, but it may expose account data. Revoke any leaked key immediately and issue a replacement.

Scope Allows
channels:read /v1/channels · /v1/channels/{channel}
templates:read /v1/templates · /v1/templates/{template}
templates:write /v1/templates · /v1/templates/{template}/submit
contacts:read /v1/contacts · /v1/contacts/{contact} · /v1/contact-imports · /v1/contact-imports/{import} · /v1/lists · /v1/lists/{list}
contacts:write /v1/contacts · /v1/contacts/{contact} · /v1/contact-imports · /v1/lists · /v1/lists/{list}/contacts · /v1/lists/{list}/contacts/{contact} · /v1/lists/{list}
messages:read /v1/messages · /v1/messages/{message} · /v1/messages/{message}/image · /v1/media/{mediaId}
messages:send /v1/messages · /v1/media
campaigns:read /v1/campaigns · /v1/campaigns/{campaign} · /v1/campaigns/{campaign}/report
campaigns:write /v1/campaigns · /v1/campaigns/{campaign}/start · /v1/campaigns/{campaign}/pause · /v1/campaigns/{campaign}/resume · /v1/campaigns/{campaign}/cancel
wallet:read /v1/balance · /v1/rates
webhooks:write /v1/webhooks · /v1/webhooks/{endpoint} · /v1/webhooks/{endpoint}/rotate-secret · /v1/webhooks/{endpoint}/retire-secret · /v1/webhooks/{endpoint}/test

Errors

Every failed response uses the same structure and includes a stable error code. Build integration logic around the code, not the message text, because messages are translated and may change.

{
  "error": {
    "code": "scope_missing",
    "message": "…",
    "request_id": "req_1a2b3c…",
    "required_scope": "messages:send"
  }
}

Every response carries a Masar-Request-Id header, repeated in the body of an error. Quote it in a support request and it resolves to exactly one call in your request log.

Code HTTP Meaning
auth_missing 401 No credential was presented. Send your key as an Authorization header: `Authorization: Bearer msr_live_…`.
auth_invalid 401 That credential is not valid. Check for a truncated or mistyped key.
auth_revoked 401 That credential has been revoked. Create a new key in the console under Developers.
auth_expired 401 That credential has expired. Create a new key in the console under Developers.
auth_ip_not_allowed 403 That credential is restricted to an address allowlist, and this request did not come from one of them.
test_key_cannot_spend 403 This is a test key, which cannot run a request that spends or settles reserved balance. Nothing was sent, charged, or settled, and no campaign changed. Use a production key from Developers when you are ready to send for real.
account_unavailable 403 The account linked to this API key is unavailable. Open the dashboard to check the account status.
plan_feature_missing 403 The API is not included in this account's plan. Upgrade to enable it.
scope_missing 403 This API key does not have the required scope. Check `required_scope`, then create a key with that scope or update the integration.
account_not_approved 403 The account is waiting for Masar approval. You can prepare the integration now; sending starts after approval.
quota_exceeded 403 The account has used its plan allowance for this period. Wait for it to reset or request a plan upgrade in the dashboard.
not_found 404 No such resource.
method_not_allowed 405 That method is not allowed on this URL.
idempotency_in_flight 409 A call with that Idempotency-Key is still being processed. Retry in a moment.
channel_unusable 409 The sending number is not ready. Open the Numbers page for its status and next action, then try again.
service_window_closed 409 Free text is only allowed within 24 hours of this person’s last message to you, and that window has closed. Send an approved template instead, or wait until they write again. Nothing was charged.
template_not_usable 409 This template is not ready to send. Check it on the Templates page and choose an approved template.
campaign_state_invalid 409 The campaign status does not allow this action. Check `current_status` before sending another request.
campaign_not_launchable 409 The campaign could not start. Check the `reason` field, correct the requirement, then try again.
cost_refused 409 The audience fee exceeds `max_cost_microusd`, so no funds were reserved. Read the actual cost in the response and retry with an appropriate limit.
resource_in_use 409 That list is the audience of a campaign that has not finished. Cancel or wait for the campaign before deleting it.
validation_failed 422 The request could not be accepted as sent.
confirmation_required 422 This call spends money or cannot be undone, so it needs "confirm": true in the body.
idempotency_conflict 422 That Idempotency-Key has already been used for a different message. Use a new key for each distinct send.
recipient_unsendable 422 This recipient cannot be messaged. Check the `reason` field before trying again.
webhook_url_refused 422 The webhook URL could not be accepted. Check the `reason` field and correct the URL before trying again.
file_unreadable 422 That upload could not be read as a CSV file.
insufficient_balance 402 The Masar management-fee balance is too low for this message. Add credit, then try again.
rate_limited 429 Too many calls on this credential. Wait for the period named in Retry-After.
pricing_unavailable 500 No rate is configured for this message. This is a problem on our side; please contact support.
server_error 500 Something went wrong on our side. Quote the request id when you contact support.

Rate limits

Limits are per KEY and per minute, never per IP address — two customers behind one office connection do not share a budget. Every response reports X-RateLimit-Limit and X-RateLimit-Remaining; a refusal carries Retry-After.

Reads and writes other than sending 600 per minute
Sending · POST /v1/messages 120 per minute
Attachment downloads · GET /v1/media/{id} 240 per minute
MCP sessions for assistants 240 per minute

Idempotency

Every write that spends money or cannot be undone requires an Idempotency-Key header: POST /v1/messages, POST /v1/campaigns, and start, pause, resume and cancel on a campaign. Any unique string will do; a UUID per call is the usual choice.

This header prevents duplicate sends or reservations when your system retries after a connection failure.

The payload is fingerprinted over the NORMALISED recipient, so a retry that reformats the number replays rather than conflicting.

Endpoints

Method Path Scope
GET /v1/me —
GET /v1/balance wallet:read
GET /v1/rates wallet:read
GET /v1/channels channels:read
GET /v1/channels/{channel} channels:read
GET /v1/templates templates:read
POST /v1/templates templates:write
GET /v1/templates/{template} templates:read
POST /v1/templates/{template}/submit templates:write
GET /v1/messages messages:read
POST /v1/messages messages:send
GET /v1/messages/{message} messages:read
GET /v1/messages/{message}/image messages:read
GET /v1/media/{mediaId} messages:read
POST /v1/media messages:send
GET /v1/webhooks webhooks:write
POST /v1/webhooks webhooks:write
PATCH /v1/webhooks/{endpoint} webhooks:write
DELETE /v1/webhooks/{endpoint} webhooks:write
POST /v1/webhooks/{endpoint}/rotate-secret webhooks:write
POST /v1/webhooks/{endpoint}/retire-secret webhooks:write
POST /v1/webhooks/{endpoint}/test webhooks:write
GET /v1/contacts contacts:read
POST /v1/contacts contacts:write
GET /v1/contacts/{contact} contacts:read
DELETE /v1/contacts/{contact} contacts:write
GET /v1/contact-imports contacts:read
POST /v1/contact-imports contacts:write
GET /v1/contact-imports/{import} contacts:read
GET /v1/lists contacts:read
POST /v1/lists contacts:write
GET /v1/lists/{list} contacts:read
POST /v1/lists/{list}/contacts contacts:write
DELETE /v1/lists/{list}/contacts/{contact} contacts:write
DELETE /v1/lists/{list} contacts:write
GET /v1/campaigns campaigns:read
POST /v1/campaigns campaigns:write
GET /v1/campaigns/{campaign} campaigns:read
GET /v1/campaigns/{campaign}/report campaigns:read
POST /v1/campaigns/{campaign}/start campaigns:write
POST /v1/campaigns/{campaign}/pause campaigns:write
POST /v1/campaigns/{campaign}/resume campaigns:write
POST /v1/campaigns/{campaign}/cancel campaigns:write

OpenAPI specification

The whole of this API is published as an OpenAPI 3.1 document at a stable URL. Point a generator at it and you get a typed client in your own language rather than a hand-written one that drifts.

https://router.atyaf.co/docs/openapi.json

The document is regenerated from available routes on every release. Every operation uses bearer authentication, and no endpoint requires an account id.

Sending a message

One approved template to one recipient. The response is 202 with the queued message; delivery is reported by webhook or by polling the message.

curl

curl -X POST https://router.atyaf.co/api/v1/messages \
  -H "Authorization: Bearer $MASAR_KEY" \
  -H "Idempotency-Key: order-1234" \
  -H "Content-Type: application/json" \
  -d '{
        "to": "+970599123456",
        "template_id": 12,
        "variables": ["Ahmad", "1234"]
      }'
HTTP/1.1 202 Accepted
Masar-Request-Id: req_1a2b3c…
X-RateLimit-Remaining: 119

{
  "data": {
    "id": 8417,
    "to": "+970599123456",
    "status": "queued",
    "dispatch_state": "pending",
    "price_microusd": 18100,
    "price": "0.0181"
  }
}

A 202 response means Masar accepted the message and added it to the send queue. It does not mean the message was delivered. Read the final status from the message resource or a webhook. Management fees are returned automatically if the message is not sent.

Replying in a conversation

Send `text` instead of `template_id` to reply to someone who wrote to you. No template is required, and Masar does not deduct a management fee for this reply. Any Meta charge appears in your Meta business account.

curl -X POST https://router.atyaf.co/api/v1/messages   -H "Authorization: Bearer $MASAR_KEY"   -H "Idempotency-Key: reply-8417"   -H "Content-Type: application/json"   -d '{
    "to": "+970599123456",
    "text": "Your order is on its way."
  }'

Only within 24 hours of that person’s last message to you. Outside it the call is refused with `service_window_closed` before anything is charged, and an approved template is the way through.

A message that arrived with a photograph or a document carries a media id. Fetch the bytes with: GET https://router.atyaf.co/api/v1/media/{id}

PHP

<?php

$response = Http::withToken($key)
    ->withHeaders(['Idempotency-Key' => 'order-1234'])
    ->post('https://router.atyaf.co/api/v1/messages', [
        'to' => '+970599123456',
        'template_id' => 12,
        'variables' => ['Ahmad', '1234'],
    ]);

if ($response->header('Idempotent-Replayed') === 'true') {
    // This retry sent nothing and charged nothing. The body is the
    // original call's answer.
}

$messageId = $response->json('data.id');

Webhooks

We POST signed events to your URL. Deliveries follow Standard Webhooks, so any conforming library verifies them without custom code.

Three headers travel with every delivery:

webhook-idmsg_…
webhook-timestamp1767225600
webhook-signaturev1,<base64> v1,<base64>

The signature is HMAC-SHA256 over the id, timestamp, and raw body joined by dots. Verify the exact bytes received, not a re-encoded object.

signed = webhook-id + "." + webhook-timestamp + "." + raw_body
signature = base64(hmac_sha256(signed, base64_decode(secret_without_prefix)))

Reject a delivery whose timestamp is more than 300 seconds from your own clock. A signature stays valid forever once captured, and that window is what stops one being replayed at you later.

Two signing secrets can remain active during rotation. Add the new secret, confirm verification still succeeds, then retire the old secret.

Verifying a delivery

<?php

/**
 * Verify one Masar delivery.
 *
 * $body MUST be the raw request body, exactly as received. Decoding it to
 * an array and re-encoding produces different bytes — a space, a unicode
 * escape, a key order — and the signature will never match again.
 */
function masar_webhook_is_valid(string $body, array $headers, array $secrets): bool
{
    $id        = $headers['webhook-id'] ?? '';
    $timestamp = (int) ($headers['webhook-timestamp'] ?? 0);
    $offered   = $headers['webhook-signature'] ?? '';

    // Replay window first. A signature stays valid forever once captured.
    if ($timestamp <= 0 || abs(time() - $timestamp) > 300) {
        return false;
    }

    $signed = $id . '.' . $timestamp . '.' . $body;

    foreach (preg_split('/\s+/', trim($offered)) as $candidate) {
        foreach ($secrets as $secret) {
            $key = base64_decode(substr($secret, strlen('whsec_')), true) ?: $secret;
            $mine = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $key, true));

            // hash_equals, never ===: a comparison that short-circuits leaks
            // the position of the first wrong byte.
            if (hash_equals($mine, $candidate)) {
                return true;
            }
        }
    }

    return false;
}

A failed delivery is retried after these intervals, in seconds: 10, 60, 300, 1800, 7200, 21600

Each retry is signed when it is sent with a new timestamp. The `webhook-id` remains the same across retries; use it to prevent processing an event twice.

An endpoint that fails 20 consecutive deliveries is disabled automatically. A 410 Gone disables it immediately — we take you at your word.

Redirects are not followed and count as a failure. The URL must be http or https on port 80 or 443, must carry no username or password, and must resolve to a public address; it is re-checked at delivery time, because DNS is mutable.

Events

One event is sent for each status change. A message moving from sent to delivered to read produces three separate events.

Event When it fires
message.accepted Masar accepted the message and added it to the send queue. Any applicable management fee was deducted. This event does not mean the message was delivered.
message.sent Handed to WhatsApp and accepted by it. The Meta message ID is available from this point on.
message.delivered The recipient device acknowledged it.
message.read The recipient opened it. Only for recipients who have read receipts switched on.
message.failed The message will not arrive. Any Masar management fee deducted for it is returned. This event is sent for an immediate refusal, a later failure, or exhausted retries.
message.received Somebody sent a message to one of your numbers. The body carries who it is from, the text if there was any, and a media id you can fetch through the media endpoint. Receiving costs nothing, so there is no price on it.
template.approved The template may now be sent. Fires whether the verdict arrived by webhook from Meta or was found by our catalogue reconciliation.
template.rejected The template was rejected. The event data includes the reason and whether the decision came from the Masar team or Meta.
channel.quality_changed The quality rating or messaging tier Meta holds for one of your numbers moved. The body is the number as it is now.
wallet.low_balance The wallet is below your auto-recharge threshold, or below what you spent last week, with the day it reaches nothing at the current rate. At most once a day.

Create a key in the dashboard