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.
- Same key, same payload: the first call's answer is replayed with Idempotent-Replayed: true, and nothing is sent or charged again.
- The same key with a different payload returns 422 with idempotency_conflict. Use a new key for every different operation.
- Same key while the first call is still running: 409 idempotency_in_flight. Retry in a moment.
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-id | msg_… |
webhook-timestamp | 1767225600 |
webhook-signature | v1,<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. |