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
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.
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 |
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. |
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 |
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.
| 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 |
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.
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 -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
$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');
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-id | msg_… |
webhook-timestamp | 1767225600 |
webhook-signature | v1,<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.
<?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.
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. |