Sagwass API
Send WhatsApp template messages, manage contacts, and have events delivered to your own systems. Everything is JSON over HTTPS, and every request acts on one workspace.
Overview
The base URL is:
https://sagwass.com/api/v1
This is version 1. Endpoints and response fields will be added over time; existing ones will not change shape or disappear without a new version. Ignore fields you do not recognise rather than failing on them — that is how new ones arrive.
Authentication
Create a key in Sagwass under Settings → Developer API. The key is shown once, at the moment it is created, and cannot be recovered afterwards — if you lose it, revoke it and make another.
Send it as a bearer token:
curl https://sagwass.com/api/v1/me \
-H "Authorization: Bearer sag_xxxxxxxxxxxx_yyyyyyyy"
The header X-API-Key is accepted as an alternative, for tools that cannot set an Authorization header.
Scopes
A key carries only the scopes you tick when you create it. A key with no scope for an endpoint is refused, and adding a scope means creating a new key.
| Scope | Allows |
|---|---|
messages:send | Send messages |
messages:read | Read conversations and messages |
contacts:read | Read contacts |
contacts:write | Create and update contacts |
templates:read | Read message templates |
webhooks:manage | Manage webhook endpoints |
Errors
Every error is JSON with a stable error.code. Branch on that, never on the message
text, which is written for people and may be reworded.
{
"error": {
"code": "insufficient_scope",
"message": "This API key does not have the \"messages:send\" scope.",
"required_scope": "messages:send"
}
}
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthenticated | Missing, malformed, revoked or expired key. |
| 403 | insufficient_scope | The key is real but lacks this scope. |
| 404 | not_found | No such record in this workspace. |
| 422 | invalid_phone | The number could not be read. |
| 422 | contact_unsubscribed | They replied STOP. See below. |
| 422 | missing_variables | A template placeholder was left empty. |
| 422 | media_required | The template has a media header; send media_url or media_id. |
| 422 | media_url_unreachable | Not a public address, redirected, or could not be downloaded. |
| 422 | media_too_large | Bigger than WhatsApp accepts for that header. |
| 422 | media_type_mismatch | The file is not the kind the header shows. |
| 422 | plan_limit_reached | The workspace's monthly message allowance is spent. |
| 422 | whatsapp_rejected | Meta refused the send; the message says why. |
| 409 | request_in_progress | An identical Idempotency-Key is still being processed. |
| 429 | — | Rate limited. Back off and retry. |
A record belonging to another workspace answers 404, not 403 — there is
nothing here to confirm the existence of.
Rate limits
120 requests a minute per key. Every response carries X-RateLimit-Limit and
X-RateLimit-Remaining; a 429 carries Retry-After.
The limit is per key, so separate keys for separate systems get separate budgets.
Workspace
Which workspace a key belongs to and what it may do. The first call to make, and the one to make when something is wrong.
{
"data": {
"workspace": { "id": 1, "name": "Dealio", "timezone": "Africa/Lagos" },
"api_key": {
"name": "Zapier",
"prefix": "8lgohmrwkcm3",
"scopes": ["messages:send", "contacts:read"],
"expires_at": null
}
}
}
Messages
Send an approved template. The recipient does not need to be an existing contact — one is created for you. WhatsApp only allows a business to open a conversation with an approved template, which is why there is no free-text send.
curl https://sagwass.com/api/v1/messages/template \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "2348011111111",
"template": "order_update",
"variables": { "body": ["A123", "shipped"] }
}'
| Field | Notes |
|---|---|
to | Required. International format, digits only, no +. |
template | The template name. Use this or template_id. |
template_id | The numeric id, if you would rather pin to one exactly. |
language | Optional, to pick between templates sharing a name. |
variables | {"body": [...], "header": [...]}. A list, where position 1 fills {{1}}; or an object keyed by placeholder number. |
from | Optional phone_number_id, for a workspace with several numbers. |
country | Optional ISO code, e.g. NG. Lets to be a local number like 08031234567 — a shop checkout usually stores it that way. |
media_url | Required for a template with an image, video or document header. A public https address we fetch. |
media_id | Instead of media_url: an id you have already uploaded to Meta yourself. |
{
"data": {
"id": 4821,
"conversation_id": 93,
"whatsapp_message_id": "wamid.HBgM…",
"to": "2348011111111",
"template": "order_update",
"status": "sent"
}
}
status here means Meta accepted it, not that it arrived. Delivery is reported later
through the message.status webhook.
contact_unsubscribed and nothing is sent. This is not something a key can override —
it is the same rule the inbox follows, and the point of it.
Reply with free text inside the 24 hours after the customer's last message — WhatsApp's customer service window. Outside it, only a template will go through, so this endpoint refuses rather than letting Meta reject it hours later.
curl https://sagwass.com/api/v1/messages/text \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": "2348011111111", "text": "On its way today." }'
| Refusal | Means |
|---|---|
no_conversation | This number has never written to you. Send a template first. |
window_closed | More than 24 hours since their last message, or they have never replied. |
country is accepted here too, for the same reason: a local number plus its ISO code
reads correctly, where the number alone does not.
Templates with a media header
A template whose header is an image, video or document needs the media with every send — WhatsApp does not keep it with the template. Give a URL and we fetch it, or upload to Meta yourself and pass the id.
curl https://sagwass.com/api/v1/messages/template \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "2348011111111",
"template": "receipt",
"media_url": "https://yourshop.com/receipts/1042.pdf",
"variables": { "body": ["1042"] }
}'
| Header | Accepts | Up to |
|---|---|---|
| Image | image/jpeg, image/png | 5 MB |
| Video | video/mp4, video/3gpp | 16 MB |
| Document | any type | 100 MB |
media_url must be a public address. Private and local addresses are refused
(media_url_unreachable), and redirects are not followed — give the final address.
A mismatch between the header and what the URL serves is refused too, rather than sending a
message that shows nothing.
/media endpoint yourself and send media_id instead. Nothing is
downloaded and nothing is stored our side.
Sending the same thing twice
A send cannot be safely repeated: ask twice and the customer gets two messages and you are billed
for two conversations. Name a request with an Idempotency-Key header and the same key
returns the first answer instead of sending again.
curl https://sagwass.com/api/v1/messages/template \
-H "Authorization: Bearer YOUR_KEY" \
-H "Idempotency-Key: woocommerce:shop.example.com:1042:status_processing" \
-H "Content-Type: application/json" \
-d '{ "to": "2348011111111", "template": "order_update", "variables": { "body": ["1042", "shipped"] } }'
- A replayed response carries
Idempotency-Replayed: true. - Keys are remembered for 24 hours and are scoped to your workspace, so your keys can never collide with another business's.
- Refusals are replayed too — a
422is a settled answer, and asking again should not have another go at something already decided. - While the first request is still running, a second with the same key gets
409 request_in_progress. Retry shortly. - Send no key and nothing is deduplicated, which is the right default for anything naturally repeatable.
woocommerce:<site host>:<order id>:<trigger>. That is exactly what
the plugin sends, so anything else computing the same string is deduplicated against it.
Contacts
Paginated. Filter with search, subscribed=1|0, per_page (max 100) and page.
{
"data": [
{
"id": 35, "name": "Ada", "phone": "2348011111111",
"email": null, "country": "NG", "subscribed": true,
"created_at": "…", "updated_at": "…"
}
],
"meta": { "page": 1, "per_page": 25, "total": 1, "last_page": 1 }
}
Takes phone, and optionally name and email. This is an
upsert: a number already known returns the existing contact rather than making a second one. An
existing name is left alone — use PATCH to rename somebody deliberately.
Send any of name, email, subscribed. Setting
subscribed: false is the same unsubscribe a STOP reply writes: it stops campaigns
and template sends to that person everywhere, not only through the API.
Templates
Read-only — templates are created and approved in Meta's system. Filter with status
and search. Each one reports the placeholders a send has to fill, so you do not have
to parse the body yourself.
{
"data": [{
"id": 5, "name": "order_update", "language": "en",
"category": "UTILITY", "status": "APPROVED",
"header_type": "TEXT", "body": "Order {{1}} is {{2}}.",
"variables": { "body": [1, 2], "header": [1] },
"sendable": true
}]
}
sendable is true only for an approved template. Sending an unapproved one is refused by Meta, not by us.
Webhook endpoints
Manage these here, or on the Developer API screen in Sagwass — they are the same thing.
Takes url (https only) and an optional events list. Leave
events out to receive everything, including events added later.
{
"data": {
"id": 3,
"url": "https://your-server.com/sagwass",
"secret": "whsec_…",
"events": ["message.received"],
"is_active": true,
"consecutive_failures": 0
}
}
The secret is returned on every read, unlike an API key — you need it again to verify deliveries when you rebuild a server.
Send url, events or is_active. Setting is_active: true also clears the failure count.
The last 50 attempts with their response codes — where to look first when nothing is arriving.
Events
| Event | Fires when |
|---|---|
message.received | A customer sent a message |
message.status | An outbound message was delivered, read, or failed |
contact.created | A new contact was added |
contact.unsubscribed | A contact opted out |
Every delivery is a POST with this envelope:
{
"id": "evt_a1b2c3d4e5f6g7h8i9j0k1l2",
"event": "message.received",
"created_at": "2026-09-09T12:03:27+00:00",
"data": {
"message_id": 4822,
"conversation_id": 93,
"from": "2348011111111",
"name": "Ada",
"text": "Where is my order?",
"type": "text",
"media_url": null,
"whatsapp_message_id": "wamid.HBgM…",
"received_at": "2026-09-09 12:03:26"
}
}
These headers come with it:
| Header | What it is |
|---|---|
X-Sagwass-Event | The event name, so you can route without parsing the body. |
X-Sagwass-Delivery | The id above. Use it to discard a repeat. |
X-Sagwass-Signature | t=<timestamp>,v1=<hmac>. See below. |
Verifying a delivery
The signature is HMAC-SHA256 over timestamp + "." + raw request body, keyed with your
endpoint secret. Verify it against the raw bytes, before parsing — re-encoding the JSON
first will change them and the check will fail.
<?php
function sagwass_verify(string $rawBody, string $header, string $secret): bool
{
parse_str(strtr($header, ',', '&'), $parts);
$timestamp = (int) ($parts['t'] ?? 0);
$signature = (string) ($parts['v1'] ?? '');
// Refuse anything older than five minutes, so a captured delivery
// cannot be replayed back at you later.
if (! $timestamp || abs(time() - $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
$raw = file_get_contents('php://input');
if (! sagwass_verify($raw, $_SERVER['HTTP_X_SAGWASS_SIGNATURE'] ?? '', 'whsec_…')) {
http_response_code(400);
exit;
}
http_response_code(200); // Acknowledge first.
// Then do the work.
Retries and ordering
- A failed delivery is retried after 1, 5 and 25 minutes, then abandoned.
- A
4xxis treated as a refusal and not retried — you read it and said no. - Acknowledge with a
2xxquickly and do your work afterwards. The request times out after 10 seconds. - Because of retries the same event can arrive twice. Discard a delivery whose
idyou have already handled. - Delivery order is not guaranteed. Use
created_atif order matters to you.
Zapier, Make and n8n
No special support is needed, and none is missing: these platforms speak REST and webhooks, which is all of this page. You can wire Sagwass into any of them today.
| To do this | Use |
|---|---|
| Start a workflow when a customer writes | Point a message.received webhook at the platform's catch URL — Webhooks by Zapier, Make's Custom webhook, or n8n's Webhook node. |
| Send a WhatsApp message from a workflow | An HTTP request to /v1/messages/template — Webhooks by Zapier, Make's HTTP module, or n8n's HTTP Request node, with your key as a bearer token. |
| Add or update a contact | POST /v1/contacts or PATCH /v1/contacts/{id} the same way. |
A useful shape for a store: send the order confirmation with
/v1/messages/template, then have message.received deliver the customer's
reply back into your helpdesk.
WooCommerce and other shops
There is a plugin: Sagwass for WooCommerce. Install it, paste an API key, and
pick a template for each order event — placed, processing, completed, cancelled. It reads your
approved templates from this API, so you choose from a list rather than typing a name, and it
sends the billing country with each number so a local 0803… is read correctly.
Building your own instead? Anything that can call a URL when an order changes can drive this.
Point it at /v1/messages/template with the customer's number, their country, and the
order fields as template variables.