Masar API reference

Send WhatsApp templates from your own system.

العربية

One REST API over the same engine the console uses. There is no second path to a recipient: the charge, the quota, the idempotency claim and the delivery lease are identical whether a message is sent from a screen or from your code.

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

Authentication

Send your key as a bearer token on every request. The key identifies the account — no endpoint takes an account id, and a resource belonging to another account answers 404.

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

A key reads msr_live_ followed by 48 characters and a 6-character checksum. Underscores, never hyphens: an underscore is not a Base64 character, so a random hash can never be mistaken for a credential.

The key is shown once, at creation, and stored only as a SHA-256 hash. It cannot be recovered — if it is lost, revoke it and create another.

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 names one scope. Grant a key only what its integration needs — a reporting key without messages:send cannot spend your balance, so leaking it is not an emergency.

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}
messages:send /v1/messages
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 failure answers with the same envelope and a stable code. Branch on the code, never on the HTTP status or the message: two refusals often share a status and have opposite remedies, and the message text is translated and may be rewritten.

{
  "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 That is a test credential, and this call moves the account balance — either by spending it or by settling money that is still reserved. Test keys read everything a live key reads and send nothing: nothing was sent, queued, charged or settled, and no campaign was started, resumed or ended. Mint a live key under Developers when you are ready to send.
account_unavailable 403 The account behind this credential is not available.
plan_feature_missing 403 The API is not included in this account's plan. Upgrade to enable it.
scope_missing 403 This credential does not carry the scope this call needs.
account_not_approved 403 This account is not approved to send yet. Sending opens once the account is reviewed.
quota_exceeded 403 This account has used its plan allowance for the current window.
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 That number cannot send right now — it may be disconnected, rate limited by Meta, or missing its token.
template_not_usable 409 That template is not in a state where this is possible.
campaign_state_invalid 409 That campaign is not in a state where this is possible. The refusal carries its current status.
campaign_not_launchable 409 That campaign cannot start. The refusal carries a reason code saying which of the conditions is not met.
cost_refused 409 The frozen audience costs more than max_cost_microusd allowed, so nothing was reserved. Read the real cost off this refusal and start again with a higher ceiling.
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 That recipient cannot be sent to.
webhook_url_refused 422 That webhook URL was refused.
file_unreadable 422 That upload could not be read as a CSV file.
insufficient_balance 402 The wallet does not have enough credit for this message.
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
POST /v1/messages 120 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.

It is required rather than optional because a duplicate charge has a refund and a duplicate WhatsApp template has a reported number — and a number's quality rating does not recover.

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/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 the live routes on every release, so an endpoint that exists is described and one that is described exists. Authentication is bearer auth on every operation; no endpoint takes 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 means charged and queued, not delivered. The wallet is debited before the send, because a charge for a message that never went out is fixed by a refund while a message sent and charged to nobody is fixed by nothing.

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 an HMAC-SHA256 over the id, the timestamp and the raw body joined by dots — verify against the exact bytes you received, never against 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 secrets can be live at once. During a rotation the signature header carries one signature per secret, space-separated, and a delivery is valid if any of them matches — so you can add the new secret, watch deliveries keep verifying, and retire the old one on your own clock.

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 on a jittered ladder, in seconds: 10, 60, 300, 1800, 7200, 21600

Each retry is signed again at the moment it is sent, so its timestamp is always fresh inside the window above. `webhook-id` never changes across the ladder — that is what you deduplicate on, never the timestamp.

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

Each event fires once per fact. A status that moves from sent to delivered to read is three deliveries, and a receipt the provider sends us twice is still one.

Event When it fires
message.accepted Charged and queued. Fires for a single send through the API and for every campaign recipient; an idempotent replay produces no second event.
message.sent Handed to WhatsApp and accepted by it. The provider message id exists from here 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 It will not arrive, and any charge has been handed back. Fires whether the provider refused the send outright, reported the failure afterwards, or we gave up 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 will not be sent, and the body carries the reason. Our own review refusing it counts as a rejection.
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 console