Download OpenAPI specification:
Send WhatsApp and SMS messages from your own systems and check delivery status — the API behind Datagroup's messaging automation. Authenticate with a per-tenant API key created in the Datagroup portal (Admin → API keys). The same key works for every channel.
Channels. Every request and response carries an explicit channel field:
whatsapp |
sms |
|
|---|---|---|
| What you send | an approved template (templateName) |
free text (content) |
| Approval needed | yes, by Meta | no |
| Threading | messages join a 24-hour conversation | none — each SMS stands alone |
| Delivery states | Sent → Delivered → Read | Sent → Delivered (no read receipt) |
| Billed by length | no | yes — per 160-character segment |
Omitting channel still defaults to whatsapp, so existing integrations are unaffected.
SMS is billed per segment. A message longer than 160 characters is split and billed as
multiple segments (70 characters if it contains any non-GSM character, such as an emoji). The
segments field on every SMS response tells you how many were billed.
Delivery-status webhooks — don't poll. Instead of calling GET /messages/{messageId} on a
timer, register one HTTPS endpoint in the portal (Admin → API keys → Delivery webhook) and we POST
every status change to it as it happens, for both channels. Polling still works, but at volume the
webhook is the supported path.
Each POST carries:
{
"event": "message.status",
"messageId": "3f9c…",
"providerMessageId": "wamid.HBg…",
"channel": "whatsapp",
"status": "Delivered",
"to": "+27821234567",
"templateName": "order_confirmation",
"errorDetail": null,
"occurredAt": "2026-08-09T13:04:31.6339263+00:00"
}
Verify the signature before trusting a payload. Two headers accompany the body:
| Header | Value |
|---|---|
X-Datagroup-Timestamp |
Unix seconds when we signed |
X-Datagroup-Signature |
sha256= + HMAC-SHA256 over the exact string {timestamp}.{raw body}, lowercase hex |
Compute the HMAC with the signing secret shown once when you registered the endpoint, using the raw request body (not a re-serialised object — any whitespace difference changes the hash), and compare with a constant-time comparison. The timestamp is inside the signed string, so a captured payload cannot be replayed with a fresh one; reject anything older than a few minutes.
Delivery is at-least-once and unordered. Retries mean you may see the same status twice, and two
transitions seconds apart can arrive out of order. Make your handler idempotent and trust
occurredAt over arrival order. Return 2xx promptly — a non-2xx is retried five times, roughly a
minute apart, after which the notification is dropped (your data is still available via
GET /messages/{messageId}).
When to retry a failed send. Retry only failures that can succeed later. A send rejected because the recipient cannot receive WhatsApp, or because the request itself is wrong, fails identically every time — resending it wastes your rate limit and, on WhatsApp, a high failure rate lowers the sending number's quality rating and messaging limit.
| Response | Retry? | What to do |
|---|---|---|
429 |
yes | Wait for Retry-After, then retry the same request |
500 |
yes | Retry once or twice with backoff |
502 |
only if the provider's code is transient (see below) | The error message carries the provider code |
400, 401, 404, 422 |
no | Fix the request, the key or the template first |
201 |
no | Accepted — the delivery outcome arrives by webhook |
On a 502 for WhatsApp, the message carries Meta's own error code:
| Meta code | Meaning | Retry? |
|---|---|---|
130429 |
throughput limit reached | yes — back off, and slow your send rate |
131000 |
unknown delivery error | yes — a few attempts |
131016, 133004 |
service temporarily unavailable | yes — with backoff |
131056 |
too many messages to this recipient too quickly | yes — after a pause; other recipients are unaffected |
131049 |
per-user marketing engagement limit | not today — wait 24 hours or more |
131026 |
undeliverable: the number is not on WhatsApp, has not accepted the terms, or runs an unsupported version | never — remove the number from the list |
131021, 131047, 131051, 132016, 132007 |
wrong recipient, closed 24-hour window, unsupported type, disabled or non-compliant template | no — the send can never succeed as-is |
131008, 131009, 132000, 132001, 132005, 132012 |
missing or invalid parameter, template/language mismatch | no — fix the request, then send again |
131031, 131042, 131045, 131048, 132015 |
account restricted, billing problem, number unregistered, quality limits | no — stop the run; every other message will fail too |
Meta's full list is at WhatsApp Cloud API error codes. Treat any code not listed here as "fix first": log it and review before resending.
How to retry. Back off exponentially with jitter (2s, 4s, 8s, 16s, 32s) and cap attempts at
about five, then record the failure. Reuse the same Idempotency-Key on every retry of a
message — that is what lets us return the original result instead of sending a second message.
A new key per attempt makes each retry a new send. Skip numbers that returned 131026 on later
campaigns rather than retrying them, and if more than about 10% of a run fails, stop and check
the recipient list before continuing.
Not yet available: free-text WhatsApp replies within the 24-hour window, WhatsApp Flows, and inbound SMS replies.
Sends a message to a South African mobile number. What the body must contain depends on
channel:
whatsapp — templateName is required; only approved templates can open a
conversation. Supply bodyParameters in order if the template has {{1}}, {{2}}, ...
placeholders (see GET /templates for each template's expected count).
Sending an OTP? Authentication templates carry a copy-code button that Meta requires a
parameter for; it is filled from your bodyParameters automatically, so send the code once
and nothing else changes. Templates in other categories with a dynamic URL button need
buttonParameters.sms — content is required and carries the message text. SMS has no
template-approval concept, so passing templateName on this channel is rejected rather
than ignored. The response's segments tells you how many segments were billed.Pass an Idempotency-Key header to make retries safe — resending the same key returns
the original result instead of sending a second message. Reusing a key always replays the
first result; it is not re-validated against a changed request body, so treat each key as
single-use per logical send.
| Idempotency-Key | string An opaque string unique to this logical send. Retrying with the same key replays the original result. |
| channel | string Default: "whatsapp" Enum: "whatsapp" "sms" Defaults to |
| to required | string South African mobile number, |
| content | string SMS only — the message text. Billed per 160-character segment (70 if it contains any non-GSM character, such as an emoji); the response's |
| templateName | string WhatsApp only — the approved template to send. |
| language | string Meta language code. Defaults to the template's own language if omitted. |
| phoneNumberId | string <uuid> Required only if you have more than one active WhatsApp number (see |
| bodyParameters | Array of strings Positional values for the template's |
object WhatsApp only, and rarely needed. Values for dynamic URL buttons, keyed by the button's zero-based position as a string — e.g. Authentication templates (OTPs) do not need it. Their copy-code / one-tap button takes the same code as the body, so it is filled from | |
| headerImageUrl | string Overrides the template's stored header image, if any. |
{- "channel": "whatsapp",
- "to": "+27821234567",
- "templateName": "order_ready",
- "language": "en_US",
- "bodyParameters": [
- "Thabo",
- "ORD-1029"
]
}{- "messageId": "8540d774-4863-4d2b-b788-4ecb19412e85",
- "providerMessageId": "string",
- "status": "Sent",
- "conversationId": "ee6e55e8-45fe-4a3e-9bc8-4669f9fdf77a",
- "channel": "whatsapp",
- "segments": 1
}Keyset-paginated list of your outbound messages, newest first. Defaults to the last 30 days if from/to are omitted.
| channel | string Enum: "whatsapp" "sms" Omit to list every channel. |
| status | string Enum: "Queued" "Sent" "Delivered" "Read" "Failed" |
| templateName | string |
| phoneNumberId | string <uuid> |
| from | string <date-time> ISO 8601 datetime; defaults to 30 days ago. |
| to | string <date-time> ISO 8601 datetime; defaults to now. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| cursor | string Opaque token from a prior page's |
{- "items": [
- {
- "messageId": "8540d774-4863-4d2b-b788-4ecb19412e85",
- "to": "string",
- "direction": "Outbound",
- "templateName": "string",
- "status": "Queued",
- "timestamp": "2019-08-24T14:15:22Z",
- "errorDetail": "string",
- "conversationId": "ee6e55e8-45fe-4a3e-9bc8-4669f9fdf77a",
- "channel": "whatsapp",
- "providerMessageId": "string",
- "segments": 0
}
], - "nextCursor": "string"
}| messageId required | string <uuid> |
{- "messageId": "8540d774-4863-4d2b-b788-4ecb19412e85",
- "to": "string",
- "direction": "Outbound",
- "templateName": "string",
- "status": "Queued",
- "timestamp": "2019-08-24T14:15:22Z",
- "errorDetail": "string",
- "conversationId": "ee6e55e8-45fe-4a3e-9bc8-4669f9fdf77a",
- "channel": "whatsapp",
- "providerMessageId": "string",
- "segments": 0
}Includes the derived bodyParameterCount so you know how many bodyParameters a send needs.
{- "items": [
- {
- "name": "string",
- "language": "string",
- "category": "UTILITY",
- "bodyParameterCount": 0,
- "hasHeaderImage": true
}
]
}Only relevant when you have more than one number — with exactly one, POST /messages uses it automatically and phoneNumberId can be omitted.
{- "items": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "displayPhoneNumber": "string",
- "isDefault": true
}
]
}