Getting started
Every request carries an API key as a bearer token. A key is scoped to one workspace and can only ever see that workspace’s data — there is no cross-tenant call. Ask us for a key, or generate one in the console under Settings.
curl https://engage.grodigital.co.za/api/v1/wallet \ -H "Authorization: Bearer eng_live_xxxxxxxxxxxxxxxx"
All requests and responses are JSON, UTF-8. Money is always in cents, never rands, so nothing depends on decimal handling. Timestamps are ISO 8601 in UTC. Lists are cursor-paginated: follow nextCursor until it comes back null.
Two things worth knowing early
- Read your rates, don’t hard-code them.
GET /api/v1/walletreturns your live rate card. Hard-coded prices go stale the day your card changes. - SMS is billed per 160-character part. A longer message is billed as two or more. One character outside the GSM-7 alphabet — an em dash, a curly quote, an emoji — drops the whole message to 70 characters per part, so a 133-character SMS can silently cost double.
Contacts
The people you can reach, and what they have consented to. Identity resolves in order: externalId, then phone, then email — so a contact synced by your own ID stays one record even if their number changes.
/api/v1/contactsCreate or update contacts
Upserts one contact or a batch of up to 100. Traits are shallow-merged into what is already stored, so you can sync a single field without sending the whole record. consent.marketing covers every channel; consent.channels.{whatsapp,email,sms} opts a person out of, or back in to, one channel. A consent change made here fires the same webhook as one made anywhere else.
Request body
| externalId | string | min length 1 · max length 255 · one of · option 1 |
| phone | string | min length 6 · max length 30 · one of · option 1 |
| string | email · max length 320 · one of · option 1 | |
| name | string | max length 500 · one of · option 1 |
| traits | object (free-form) | free-form keys · one of · option 1 |
| consent | object | one of · option 1 |
| consent.popia | boolean | one of · option 1 |
| consent.marketing | "opt_in" | "opt_out" | one of · option 1 |
| consent.resubscribed | boolean | one of · option 1 |
| consent.channels | object | one of · option 1 |
| consent.channels.whatsapp | "opt_in" | "opt_out" | one of · option 1 |
| consent.channels.email | "opt_in" | "opt_out" | one of · option 1 |
| consent.channels.sms | "opt_in" | "opt_out" | one of · option 1 |
| [].externalId | string | min length 1 · max length 255 · one of · option 2 · batch |
| [].phone | string | min length 6 · max length 30 · one of · option 2 · batch |
| string | email · max length 320 · one of · option 2 · batch | |
| [].name | string | max length 500 · one of · option 2 · batch |
| [].traits | object (free-form) | free-form keys · one of · option 2 · batch |
| [].consent | object | one of · option 2 · batch |
| [].consent.popia | boolean | one of · option 2 · batch |
| [].consent.marketing | "opt_in" | "opt_out" | one of · option 2 · batch |
| [].consent.resubscribed | boolean | one of · option 2 · batch |
| [].consent.channels | object | one of · option 2 · batch |
| [].consent.channels.whatsapp | "opt_in" | "opt_out" | one of · option 2 · batch |
| [].consent.channels.email | "opt_in" | "opt_out" | one of · option 2 · batch |
| [].consent.channels.sms | "opt_in" | "opt_out" | one of · option 2 · batch |
* required · generated from the schema this endpoint validates against
Response
{
"data": { "id": "ct_9f2a...", "status": "created" }
}Rate limit · 600 requests per minute per key
/api/v1/contactsList contacts
Cursor-paginated. Pass the previous response’s nextCursor to continue; a null nextCursor means you have reached the end.
Query parameters
| cursor | string | nextCursor from the previous page |
| limit | number | default 100, maximum 100 |
Response
{
"data": [
{
"id": "ct_9f2a...",
"externalId": "user-1024",
"phone": "+27820000000",
"email": "someone@example.com",
"consent": {
"popiaAt": "2026-08-01T09:12:00.000Z", "marketingAt": "2026-08-01T09:12:00.000Z", "optedOutAt": null,
"channels": { "whatsapp": { "optedOutAt": null }, "email": { "optedOutAt": "2026-09-02T14:03:00.000Z" }, "sms": { "optedOutAt": null } }
}
}
],
"nextCursor": "ct_9f2a..."
}Rate limit · 600 requests per minute per key
/api/v1/contactsDelete a contact
Removes the contact your platform knows by this externalId, with its conversations and campaign history. Call it when a person is deleted or merged away on your side, so the record here does not live on holding their phone number.
Query parameters
| externalId | string | your platform’s id for the person |
Response
{ "data": { "id": "ct_9f2a...", "status": "deleted" } }Rate limit · 600 requests per minute per key
Campaigns
A message to an audience, on one channel. Opt-outs and unreachable contacts are excluded before the send, so you are never billed to message someone who cannot hear you.
/api/v1/campaignsList campaigns with reports
Every campaign, newest first, each with its delivery report and settled cost. One row per channel: a send that went out on WhatsApp and email is two rows sharing a groupId. This is the feed a reporting page aggregates from, so it is paged and filterable by date.
Query parameters
| since | date | created on or after, YYYY-MM-DD |
| until | date | created on or before |
| status | string | draft, scheduled, running, completed, failed, cancelled |
| channel | string | whatsapp, email or sms |
| groupId | string | the rows of one multi-channel send |
| cursor | string | nextCursor from the previous page |
| limit | number | default 50, maximum 100 |
Response
{
"data": [
{
"id": "cmp_7d1e...", "name": "Spring bursaries", "channel": "whatsapp", "groupId": "grp_2a...",
"status": "completed", "startedAt": "2026-09-02T08:00:00.000Z", "completedAt": "2026-09-02T08:04:11.000Z",
"report": { "sent": 12, "delivered": 980, "read": 610, "replied": 41, "failed": 9, "skipped_no_consent": 3, "reached": 1631, "opened": 651, "clicked": 0 },
"costCents": 104300
}
],
"nextCursor": null
}Rate limit · 600 requests per minute per key
/api/v1/campaigns/{id}/failuresWhy people did not get it
Recipients who were skipped or failed, grouped by cause: bad numbers, no WhatsApp on the number, bounced email, spam complaints, and the two consent-gate skips. Each group carries up to 25 of the people affected with your externalId, so the record can be fixed on your side. disposition says whether sending again would help.
Response
{
"data": [
{ "code": "no_whatsapp", "label": "Number is not on WhatsApp", "kind": "failed", "disposition": "permanent", "count": 6,
"people": [{ "contactId": "ct_9f2a...", "externalId": "user-1024", "label": "+27820000000 · A Person" }], "more": 0 }
]
}Rate limit · 600 requests per minute per key
/api/v1/campaignsCreate a campaign
Creates a draft, or sends immediately with start: true. Audience is one of three shapes: an existing segmentId, an inline filter, or an explicit recipient list of up to 25,000. Recipients that match no contact are returned in unmatchedIndexes rather than silently dropped.
Request body
| name* | string | min length 1 · max length 200 |
| channel | "whatsapp" | "email" | "sms" | default "whatsapp" |
| message* | object | |
| message.type* | "template" | "text" | "email" | "sms" | one of · option 1 · one of · option 2 · one of · option 3 · one of · option 4 |
| message.name* | string | min length 1 · one of · option 1 |
| message.language | string | default "en" · min length 2 · one of · option 1 |
| message.variables | string[] | max 20 items · one of · option 1 |
| message.headerImageUrl | string | url · max length 2000 · one of · option 1 |
| message.components | any[] | one of · option 1 |
| message.body* | string | min length 1 · max length 4096 · one of · option 2 · max length 459 · one of · option 4 |
| message.subject* | string | min length 1 · max length 300 · one of · option 3 |
| message.html* | string | min length 1 · max length 200000 · one of · option 3 |
| audience* | object | |
| audience.segmentId* | string | min length 1 · one of · option 1 |
| audience.filter* | object | one of · option 2 |
| audience.filter.match | "all" | "any" | default "all" · one of · option 2 |
| audience.filter.rules | object[] | default [] · max 20 items · one of · option 2 |
| audience.filter.rules[].trait* | string | min length 1 · max length 100 · one of · option 2 |
| audience.filter.rules[].op* | "eq" | "neq" | "contains" | "gt" | "lt" | "exists" | "not_exists" | one of · option 2 |
| audience.filter.rules[].value | string | number | boolean | one of · option 2 |
| audience.recipients* | object[] | min 1 item · max 25000 items · one of · option 3 |
| audience.recipients[].externalId | string | one of · option 3 |
| audience.recipients[].phone | string | one of · option 3 |
| start | boolean | default false |
| scheduledAt | string (ISO 8601) |
* required · generated from the schema this endpoint validates against
Response
{
"data": { "id": "cm_4b71...", "status": "running", "matched": 1840, "unmatchedIndexes": [12, 907] }
}Rate limit · 600 requests per minute per key
/api/v1/campaigns/{id}Campaign report
Live delivery counters and the metered cost so far. Safe to poll while a campaign runs.
Response
{
"data": {
"id": "cm_4b71...",
"status": "running",
"counts": { "pending": 120, "sent": 1720, "delivered": 1688, "read": 1301, "replied": 344, "failed": 0 },
"costCents": 172000
}
}Rate limit · 600 requests per minute per key
/api/v1/campaigns/{id}Cancel a scheduled campaign
Undoes a schedule before it starts: the campaign returns to draft and can be scheduled again. A campaign that has begun sending cannot be cancelled; the reply is 409.
Response
{ "data": { "id": "cm_4b71...", "status": "draft" } }Rate limit · 600 requests per minute per key
Conversations
Replies land here. Every inbound message opens a 24-hour window in which you can answer freely; outside it, WhatsApp requires an approved template.
/api/v1/conversationsList conversations
Most recently active first, with unread counts, so you can render an inbox inside your own product.
Query parameters
| cursor | string | nextCursor from the previous page |
| limit | number | default 50, maximum 100 |
Rate limit · 600 requests per minute per key
/api/v1/conversations/sendSend a free-form reply
Only works inside the 24-hour window opened by an inbound message. Outside it the call is rejected — send an approved template instead.
Request body
| channel* | "whatsapp" | |
| contact* | object | |
| contact.externalId | string | min length 1 |
| contact.phone | string | min length 6 |
| contact.conversationId | string | min length 1 |
| text* | string | min length 1 · max length 4096 |
* required · generated from the schema this endpoint validates against
Response
{
"data": { "conversationId": "cv_2d19...", "messageId": "ms_77c0..." }
}Rate limit · 600 requests per minute per key
/api/v1/conversations/readMark a conversation read
Clears the unread count, so your inbox and the Engage console agree.
Request body
| conversationId* | string | min length 1 |
* required · generated from the schema this endpoint validates against
Rate limit · 600 requests per minute per key
Templates
WhatsApp requires a pre-approved template for anything outside the 24-hour window. These endpoints manage yours and report where each one is in Meta review.
/api/v1/templatesList templates and their approval status
Rate limit · 600 requests per minute per key
/api/v1/templatesSubmit a template for approval
Creates the template and submits it to Meta. Approval is asynchronous — watch the status field or subscribe to the webhook.
Request body
| name* | string | min length 1 |
| language | string | default "en" · min length 2 |
| category | "MARKETING" | "UTILITY" | "AUTHENTICATION" | |
| headerText | string | null | |
| headerImageAssetId | string | null | |
| body | string | min length 1 · max length 1024 |
| footerText | string | null | |
| buttons | any[] | |
| variableDefaults | string[] |
* required · generated from the schema this endpoint validates against
Rate limit · 600 requests per minute per key
/api/v1/templatesEdit and resubmit a template
Meta treats an edit as a fresh review, so an approved template returns to pending.
Request body
| name* | string | min length 1 |
| language | string | default "en" · min length 2 |
| category | "MARKETING" | "UTILITY" | "AUTHENTICATION" | |
| headerText | string | null | |
| headerImageAssetId | string | null | |
| body | string | min length 1 · max length 1024 |
| footerText | string | null | |
| buttons | any[] | |
| variableDefaults | string[] |
* required · generated from the schema this endpoint validates against
Rate limit · 600 requests per minute per key
/api/v1/templatesDelete a template
Query parameters
| name | string | template name |
Rate limit · 600 requests per minute per key
/api/v1/templates/mediaUpload a header image
Base64 in, asset id out. Pass that id as headerImageAssetId when creating the template.
Request body
| filename* | string | min length 1 · max length 255 |
| dataBase64* | string | min length 1 · max length 8388608 |
* required · generated from the schema this endpoint validates against
Rate limit · 600 requests per minute per key
Events
Behavioural events from your product. They power segments — "everyone who started an application and did not finish" is a filter over these, not a list you maintain.
/api/v1/eventsRecord events
One event or a batch of up to 100. Attach a contact by externalId, phone or email; an event with no contact is still stored.
Request body
| name* | string | min length 1 · max length 200 · one of · option 1 |
| contact | object | one of · option 1 |
| contact.externalId | string | min length 1 · one of · option 1 |
| contact.phone | string | min length 6 · one of · option 1 |
| contact.email | string | email · one of · option 1 |
| properties | object (free-form) | free-form keys · one of · option 1 |
| occurredAt | any | one of · option 1 |
| [].name* | string | min length 1 · max length 200 · one of · option 2 · batch |
| [].contact | object | one of · option 2 · batch |
| [].contact.externalId | string | min length 1 · one of · option 2 · batch |
| [].contact.phone | string | min length 6 · one of · option 2 · batch |
| [].contact.email | string | email · one of · option 2 · batch |
| [].properties | object (free-form) | free-form keys · one of · option 2 · batch |
| [].occurredAt | any | one of · option 2 · batch |
* required · generated from the schema this endpoint validates against
Response
{
"data": { "id": "ev_18ca...", "status": "created" }
}Rate limit · 600 requests per minute per key
Wallet
Prepaid balance, the rate card you are billed against, and top-ups. Read rates from here rather than hard-coding them — when your card changes, your cost estimates follow automatically.
/api/v1/walletBalance, rates and recent entries
Everything a wallet page needs. rates is your live rate card in cents; multiply by segments for SMS, where a message over 160 characters is billed as more than one. billingActive tells you whether sending is enabled — armed is the old name for the same field and is deprecated.
Response
{
"balanceCents": 250000,
"reservedCents": 0,
"availableCents": 250000,
"billingActive": true,
"lowBalance": false,
"rates": { "waMarketingCents": 100, "waUtilityCents": 50, "waAuthCents": 50, "emailCents": 10, "smsCents": 30 },
"entries": [{ "kind": "TOPUP_CARD", "amountCents": 250000, "createdAt": "2026-08-01T08:00:00.000Z" }]
}Rate limit · 600 requests per minute per key
/api/v1/walletSet the billing email
Where receipts and low-balance notices go.
Request body
| billingEmail* | string | email · max length 320 |
* required · generated from the schema this endpoint validates against
Rate limit · 60 requests per minute per key
/api/v1/wallet/entriesThe full ledger
Every movement on the balance, newest first, paged, with a date range. format=csv returns the same rows as a file for the accountant. GET /wallet stops at the last 30; this does not.
Query parameters
| from | date | YYYY-MM-DD, inclusive |
| to | date | YYYY-MM-DD, inclusive |
| type | string | topup_card, topup_eft, debit_usage, refund, adjustment |
| cursor | string | nextCursor from the previous page |
| limit | number | default 50, maximum 100 |
| format | string | csv for a download; up to 10,000 rows |
Response
{
"data": [{ "id": "we_...", "type": "debit_usage", "amountCents": -104300, "balanceAfterCents": 145700, "reference": "campaign:cmp_7d1e...", "note": "Spring bursaries", "at": "2026-09-02T08:04:11.000Z" }],
"nextCursor": null
}Rate limit · 600 requests per minute per key
/api/v1/wallet/topupsTop-up history
Every top-up started, with its Paystack reference and, once paid, enough of the receipt to recognise the payment: card type and last four digits. Nothing here can charge the card.
Query parameters
| status | string | pending, paid or expired |
| cursor | string | nextCursor from the previous page |
| limit | number | default 50, maximum 100 |
Response
{
"data": [{ "id": "wt_...", "reference": "engtu_...", "amountCents": 250000, "status": "paid", "payerEmail": "finance@example.com", "paidAt": "2026-09-01T08:00:00.000Z", "receipt": { "channel": "card", "cardType": "visa", "last4": "4081", "bank": "TEST BANK" } }],
"nextCursor": null
}Rate limit · 600 requests per minute per key
/api/v1/wallet/topupsStart a top-up
Returns a Paystack authorisation URL to send the payer to. The balance moves only once the payment is verified.
Request body
| amountCents* | integer | integer · min 10000 · max 10000000 |
| payerEmail | string | email · max length 320 |
* required · generated from the schema this endpoint validates against
Response
{
"reference": "tp_5a90...",
"authorizationUrl": "https://checkout.paystack.com/..."
}Rate limit · 20 requests per minute per key
/api/v1/wallet/topups/verifyVerify a top-up
Confirms a payment and credits the wallet. Idempotent — verifying twice credits once.
Request body
| reference* | string | min length 1 · max length 100 |
* required · generated from the schema this endpoint validates against
Rate limit · 120 requests per minute per key
/api/v1/wallet/autotopupConfigure automatic top-up
Charges a saved card when the balance falls below your threshold, so a campaign never stops mid-send.
Request body
| enabled* | boolean | |
| thresholdCents | integer | integer · min 10000 · max 10000000 |
| amountCents | integer | integer · min 10000 · max 10000000 |
* required · generated from the schema this endpoint validates against
Rate limit · 60 requests per minute per key
Billing
What you are charged, month by month. A statement is computed from the metering and the rate card in force; at month end it freezes into an invoice. Usage is paid from the wallet as campaigns run, so the amount due on an invoice is normally just the platform fee.
/api/v1/billing/statementsMonthly statement
One period, or a range of up to 24 (from/to). Lines are per channel at your rates; paidFromWalletCents is the usage the balance already covered; dueCents is what remains. The current period includes a linear projection.
Query parameters
| period | string | YYYY-MM; defaults to the current month |
| from | string | YYYY-MM; with to, returns an array |
| to | string | YYYY-MM; defaults to the current month |
Response
{
"data": {
"period": "2026-08", "currency": "ZAR",
"lines": [{ "key": "wa_marketing", "description": "WhatsApp — marketing messages", "quantity": 1043, "unitCents": 100, "amountCents": 104300 }],
"baseFeeCents": 2150000, "subtotalCents": 104300, "totalCents": 2254300,
"paidFromWalletCents": 104300, "dueCents": 2150000,
"isCurrentPeriod": false, "projectedTotalCents": null,
"invoice": { "id": "inv_...", "number": "ENG-EXAMPLE-0007", "status": "PAID", "issuedAt": "2026-09-01T06:00:00.000Z", "dueAt": "2026-09-08T06:00:00.000Z", "paidAt": "2026-09-05T10:12:00.000Z" }
}
}Rate limit · 600 requests per minute per key
/api/v1/billing/invoicesList invoices
Every invoice raised, newest period first. Line items and totals are frozen at issue. overdue is true for an issued invoice past its due date.
Query parameters
| cursor | string | nextCursor from the previous page |
| limit | number | default 50, maximum 100 |
Rate limit · 600 requests per minute per key
/api/v1/billing/invoices/{id}One invoice
The same record with the billed-to name, for rendering a printable copy.
Rate limit · 600 requests per minute per key
Reports
Aggregates across campaigns and contacts, for a reporting page. Per-campaign detail lives under Campaigns.
/api/v1/reports/usageMessages and spend by channel by month
The metered counts behind each statement, priced at your rates, for a range of up to 24 months. Defaults to the last twelve.
Query parameters
| from | string | YYYY-MM |
| to | string | YYYY-MM; defaults to the current month |
Response
{
"data": [{ "period": "2026-08", "lines": [{ "key": "sms", "channel": "sms", "category": "standard", "quantity": 4120, "unitCents": 30, "amountCents": 123600 }], "baseFeeCents": 2150000, "subtotalCents": 123600, "totalCents": 2273600, "projectedTotalCents": null }]
}Rate limit · 600 requests per minute per key
/api/v1/reports/consentOpt-ins, opt-outs and reach
timeline: consent changes by month, kind, scope (all_channels or one channel) and route — a STOP on WhatsApp, an email unsubscribe, a spam complaint, a sync. snapshot: how many contacts can be reached on each channel today, how many have opted out of what, and new contacts per month.
Query parameters
| from | string | YYYY-MM |
| to | string | YYYY-MM; defaults to the current month |
Response
{
"data": {
"from": "2025-09", "to": "2026-09",
"timeline": [{ "month": "2026-09", "kind": "opt_out", "scope": "whatsapp", "source": "whatsapp_keyword", "count": 14 }],
"snapshot": { "contacts": 35210, "consented": 33980, "reachable": { "whatsapp": 33100, "email": 21400, "sms": 33100 }, "optedOut": { "all": 1230, "whatsapp": 140, "email": 380, "sms": 22 }, "newContactsByMonth": [{ "month": "2026-09", "count": 2100 }] }
}
}Rate limit · 600 requests per minute per key
Channels
Which rails are live for you, and the state of each.
/api/v1/channelsChannel status
Whether WhatsApp, SMS and email are configured and active on your workspace.
Rate limit · 600 requests per minute per key
Webhooks
Engage POSTs to your endpoint when something happens in the workspace. Add the URL and pick your events in the console under Settings → Webhooks; the signing secret is shown once, when the webhook is created.
Events
| message.received | Someone replied to you on any channel. |
| message.status_changed | A message was delivered, read or failed. |
| contact.opted_out | A contact withdrew marketing consent — stop sending immediately. data.scope says how far: "all_channels", or one of "whatsapp" / "email" / "sms" when they only stopped that channel. data.state gives the resulting position on every channel. |
| contact.opted_in | A contact gave marketing consent, with the same scope and state fields. |
| wallet.topup_succeeded | A top-up cleared and the balance went up. |
| wallet.debited | A campaign settled against the wallet. |
| wallet.low_balance | The available balance fell below the warning level. |
Request
POST your-endpoint
Content-Type: application/json
X-Engage-Event: contact.opted_out // convenience only — NOT signed
X-Engage-Timestamp: 1755432000
X-Engage-Signature: sha256=9f2a...
{
"id": "whd_...", // delivery id, stable across retries
"event": "contact.opted_out", // <- route on THIS, it is inside the signature
"createdAt": "2026-08-18T14:05:00.000Z",
"data": { } // event payload
}Verifying the signature
HMAC-SHA256 over `${timestamp}.${rawBody}`, hex encoded, keyed with your signing secret. Sign the raw body exactly as received — parsing and re-serialising the JSON changes the bytes and the signature will not match. The timestamp is Unix seconds; reject anything older than a few minutes so a captured delivery cannot be replayed, and compare digests in constant time.
Route on the event field in the body, not on the X-Engage-Event header. The signature covers `${timestamp}.${rawBody}` and nothing else, so the header is outside it and is a debugging convenience only. Branching on it means acting on a value nobody signed — which matters most for the one event you least want spoofed or suppressed, contact.opted_out. The timestamp header is safe to read: it is part of the signed string, so altering it breaks verification.
import { createHmac, timingSafeEqual } from 'crypto';
function verify(rawBody, headers, secret) {
const ts = headers['x-engage-timestamp'];
const got = String(headers['x-engage-signature'] ?? '').replace('sha256=', '');
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const want = createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
const a = Buffer.from(got, 'hex');
const b = Buffer.from(want, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}Delivery
Return 2xx within 10 seconds. Anything else is retried with exponential backoff, up to 5 attempts, after which the delivery is marked exhausted and kept for inspection rather than dropped. Retries reuse the same id, so treat it as an idempotency key — a delivery can arrive more than once and your handler should be safe to run twice.
Errors
Errors carry a stable machine-readable code. Match on error.code, never on the message.
{ "error": { "code": "validation_failed", "issues": [ … ] } }| 400 | invalid_json | The body was not valid JSON. |
| 401 | unauthorized | Missing, malformed or revoked API key. |
| 404 | conversation_not_found | No such record in your workspace. |
| 422 | validation_failed | The body did not match the schema. issues lists each problem. |
| 422 | unknown_segment | The segmentId does not exist in your workspace. |
| 429 | rate_limited | Too many requests. Back off and retry. |
| 502 | paystack_error | The payment provider rejected or failed the call. |
Something missing, or a response that doesn’t match what you see here? Tell us — this page is generated from the code, so if it is wrong the code is wrong too.
