Sagwass

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.

ScopeAllows
messages:sendSend messages
messages:readRead conversations and messages
contacts:readRead contacts
contacts:writeCreate and update contacts
templates:readRead message templates
webhooks:manageManage webhook endpoints
A key belongs to the workspace, not to the person who made it. It keeps working when staff change — so revoke any key you no longer recognise.

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"
  }
}
StatusCodeMeaning
401unauthenticatedMissing, malformed, revoked or expired key.
403insufficient_scopeThe key is real but lacks this scope.
404not_foundNo such record in this workspace.
422invalid_phoneThe number could not be read.
422contact_unsubscribedThey replied STOP. See below.
422missing_variablesA template placeholder was left empty.
422media_requiredThe template has a media header; send media_url or media_id.
422media_url_unreachableNot a public address, redirected, or could not be downloaded.
422media_too_largeBigger than WhatsApp accepts for that header.
422media_type_mismatchThe file is not the kind the header shows.
422plan_limit_reachedThe workspace's monthly message allowance is spent.
422whatsapp_rejectedMeta refused the send; the message says why.
409request_in_progressAn identical Idempotency-Key is still being processed.
429Rate 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

GET /v1/me no scope required

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

POST /v1/messages/template messages:send

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"] }
  }'
FieldNotes
toRequired. International format, digits only, no +.
templateThe template name. Use this or template_id.
template_idThe numeric id, if you would rather pin to one exactly.
languageOptional, to pick between templates sharing a name.
variables{"body": [...], "header": [...]}. A list, where position 1 fills {{1}}; or an object keyed by placeholder number.
fromOptional phone_number_id, for a workspace with several numbers.
countryOptional ISO code, e.g. NG. Lets to be a local number like 08031234567 — a shop checkout usually stores it that way.
media_urlRequired for a template with an image, video or document header. A public https address we fetch.
media_idInstead 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.

Unsubscribed contacts are refused. Someone who replied STOP gets 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.
POST /v1/messages/text messages:send

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." }'
RefusalMeans
no_conversationThis number has never written to you. Send a template first.
window_closedMore 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.

An unsubscribe does not block this one. Someone who opted out of marketing and then writes to you is waiting for an answer. That is the difference between this endpoint and the template one, and the reason they are two.

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"] }
  }'
HeaderAcceptsUp to
Imageimage/jpeg, image/png5 MB
Videovideo/mp4, video/3gpp16 MB
Documentany type100 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.

Would rather we did not fetch from your servers at all? Upload to Meta's /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 422 is 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.
Connected two ways at once? A shop running the WooCommerce plugin while an older webhook setup is still configured will fire the same notification down both paths. Derive the key from the order rather than from the sender and it does not matter which arrives first: 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

GET /v1/contacts contacts:read

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 }
}
GET /v1/contacts/{id} contacts:read
POST /v1/contacts contacts:write

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.

PATCH /v1/contacts/{id} contacts:write

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.

DELETE /v1/contacts/{id} contacts:write

Templates

GET /v1/templates templates:read

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.

GET /v1/templates/{id} templates:read

Webhook endpoints

Manage these here, or on the Developer API screen in Sagwass — they are the same thing.

GET /v1/webhooks webhooks:manage
POST /v1/webhooks webhooks:manage

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.

PATCH /v1/webhooks/{id} webhooks:manage

Send url, events or is_active. Setting is_active: true also clears the failure count.

DELETE /v1/webhooks/{id} webhooks:manage
GET /v1/webhooks/deliveries webhooks:manage

The last 50 attempts with their response codes — where to look first when nothing is arriving.

Events

EventFires when
message.receivedA customer sent a message
message.statusAn outbound message was delivered, read, or failed
contact.createdA new contact was added
contact.unsubscribedA 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:

HeaderWhat it is
X-Sagwass-EventThe event name, so you can route without parsing the body.
X-Sagwass-DeliveryThe id above. Use it to discard a repeat.
X-Sagwass-Signaturet=<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 4xx is treated as a refusal and not retried — you read it and said no.
  • Acknowledge with a 2xx quickly 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 id you have already handled.
  • Delivery order is not guaranteed. Use created_at if 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 thisUse
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/templateWebhooks 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.

Running SagMultiStore? Connect it under Integrations instead. That link is two-way: order and abandoned-basket events arrive automatically, and the AI agent can read live prices, stock and order status to answer customers itself.
Something not here that you need? Tell us at support@sagwass.com — this is version 1 and it is deliberately small.