Reference

The Engage API.

The REST API behind Engage. Sync contacts and consent, launch campaigns, read replies and manage your wallet — everything the console does, your product can do.

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/wallet returns 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.

POST/api/v1/contacts

Create 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

externalIdstringmin length 1 · max length 255 · one of · option 1
phonestringmin length 6 · max length 30 · one of · option 1
emailstringemail · max length 320 · one of · option 1
namestringmax length 500 · one of · option 1
traitsobject (free-form)free-form keys · one of · option 1
consentobjectone of · option 1
consent.popiabooleanone of · option 1
consent.marketing"opt_in" | "opt_out"one of · option 1
consent.resubscribedbooleanone of · option 1
consent.channelsobjectone 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
[].externalIdstringmin length 1 · max length 255 · one of · option 2 · batch
[].phonestringmin length 6 · max length 30 · one of · option 2 · batch
[].emailstringemail · max length 320 · one of · option 2 · batch
[].namestringmax length 500 · one of · option 2 · batch
[].traitsobject (free-form)free-form keys · one of · option 2 · batch
[].consentobjectone of · option 2 · batch
[].consent.popiabooleanone of · option 2 · batch
[].consent.marketing"opt_in" | "opt_out"one of · option 2 · batch
[].consent.resubscribedbooleanone of · option 2 · batch
[].consent.channelsobjectone 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

GET/api/v1/contacts

List contacts

Cursor-paginated. Pass the previous response’s nextCursor to continue; a null nextCursor means you have reached the end.

Query parameters

cursorstringnextCursor from the previous page
limitnumberdefault 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

DELETE/api/v1/contacts

Delete 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

externalIdstringyour 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.

GET/api/v1/campaigns

List 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

sincedatecreated on or after, YYYY-MM-DD
untildatecreated on or before
statusstringdraft, scheduled, running, completed, failed, cancelled
channelstringwhatsapp, email or sms
groupIdstringthe rows of one multi-channel send
cursorstringnextCursor from the previous page
limitnumberdefault 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

GET/api/v1/campaigns/{id}/failures

Why 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

POST/api/v1/campaigns

Create 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*stringmin 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*stringmin length 1 · one of · option 1
message.languagestringdefault "en" · min length 2 · one of · option 1
message.variablesstring[]max 20 items · one of · option 1
message.headerImageUrlstringurl · max length 2000 · one of · option 1
message.componentsany[]one of · option 1
message.body*stringmin length 1 · max length 4096 · one of · option 2 · max length 459 · one of · option 4
message.subject*stringmin length 1 · max length 300 · one of · option 3
message.html*stringmin length 1 · max length 200000 · one of · option 3
audience*object
audience.segmentId*stringmin length 1 · one of · option 1
audience.filter*objectone of · option 2
audience.filter.match"all" | "any"default "all" · one of · option 2
audience.filter.rulesobject[]default [] · max 20 items · one of · option 2
audience.filter.rules[].trait*stringmin 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[].valuestring | number | booleanone of · option 2
audience.recipients*object[]min 1 item · max 25000 items · one of · option 3
audience.recipients[].externalIdstringone of · option 3
audience.recipients[].phonestringone of · option 3
startbooleandefault false
scheduledAtstring (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

GET/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

DELETE/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.

GET/api/v1/conversations

List conversations

Most recently active first, with unread counts, so you can render an inbox inside your own product.

Query parameters

cursorstringnextCursor from the previous page
limitnumberdefault 50, maximum 100

Rate limit · 600 requests per minute per key

POST/api/v1/conversations/send

Send 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.externalIdstringmin length 1
contact.phonestringmin length 6
contact.conversationIdstringmin length 1
text*stringmin 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

POST/api/v1/conversations/read

Mark a conversation read

Clears the unread count, so your inbox and the Engage console agree.

Request body

conversationId*stringmin 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.

GET/api/v1/templates

List templates and their approval status

Rate limit · 600 requests per minute per key

POST/api/v1/templates

Submit 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*stringmin length 1
languagestringdefault "en" · min length 2
category"MARKETING" | "UTILITY" | "AUTHENTICATION"
headerTextstring | null
headerImageAssetIdstring | null
bodystringmin length 1 · max length 1024
footerTextstring | null
buttonsany[]
variableDefaultsstring[]

* required · generated from the schema this endpoint validates against

Rate limit · 600 requests per minute per key

PATCH/api/v1/templates

Edit and resubmit a template

Meta treats an edit as a fresh review, so an approved template returns to pending.

Request body

name*stringmin length 1
languagestringdefault "en" · min length 2
category"MARKETING" | "UTILITY" | "AUTHENTICATION"
headerTextstring | null
headerImageAssetIdstring | null
bodystringmin length 1 · max length 1024
footerTextstring | null
buttonsany[]
variableDefaultsstring[]

* required · generated from the schema this endpoint validates against

Rate limit · 600 requests per minute per key

DELETE/api/v1/templates

Delete a template

Query parameters

namestringtemplate name

Rate limit · 600 requests per minute per key

POST/api/v1/templates/media

Upload a header image

Base64 in, asset id out. Pass that id as headerImageAssetId when creating the template.

Request body

filename*stringmin length 1 · max length 255
dataBase64*stringmin 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.

POST/api/v1/events

Record 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*stringmin length 1 · max length 200 · one of · option 1
contactobjectone of · option 1
contact.externalIdstringmin length 1 · one of · option 1
contact.phonestringmin length 6 · one of · option 1
contact.emailstringemail · one of · option 1
propertiesobject (free-form)free-form keys · one of · option 1
occurredAtanyone of · option 1
[].name*stringmin length 1 · max length 200 · one of · option 2 · batch
[].contactobjectone of · option 2 · batch
[].contact.externalIdstringmin length 1 · one of · option 2 · batch
[].contact.phonestringmin length 6 · one of · option 2 · batch
[].contact.emailstringemail · one of · option 2 · batch
[].propertiesobject (free-form)free-form keys · one of · option 2 · batch
[].occurredAtanyone 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.

GET/api/v1/wallet

Balance, 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

PATCH/api/v1/wallet

Set the billing email

Where receipts and low-balance notices go.

Request body

billingEmail*stringemail · max length 320

* required · generated from the schema this endpoint validates against

Rate limit · 60 requests per minute per key

GET/api/v1/wallet/entries

The 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

fromdateYYYY-MM-DD, inclusive
todateYYYY-MM-DD, inclusive
typestringtopup_card, topup_eft, debit_usage, refund, adjustment
cursorstringnextCursor from the previous page
limitnumberdefault 50, maximum 100
formatstringcsv 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

GET/api/v1/wallet/topups

Top-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

statusstringpending, paid or expired
cursorstringnextCursor from the previous page
limitnumberdefault 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

POST/api/v1/wallet/topups

Start 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*integerinteger · min 10000 · max 10000000
payerEmailstringemail · 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

POST/api/v1/wallet/topups/verify

Verify a top-up

Confirms a payment and credits the wallet. Idempotent — verifying twice credits once.

Request body

reference*stringmin length 1 · max length 100

* required · generated from the schema this endpoint validates against

Rate limit · 120 requests per minute per key

POST/api/v1/wallet/autotopup

Configure 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
thresholdCentsintegerinteger · min 10000 · max 10000000
amountCentsintegerinteger · 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.

GET/api/v1/billing/statements

Monthly 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

periodstringYYYY-MM; defaults to the current month
fromstringYYYY-MM; with to, returns an array
tostringYYYY-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

GET/api/v1/billing/invoices

List 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

cursorstringnextCursor from the previous page
limitnumberdefault 50, maximum 100

Rate limit · 600 requests per minute per key

GET/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.

GET/api/v1/reports/usage

Messages 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

fromstringYYYY-MM
tostringYYYY-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

Channels

Which rails are live for you, and the state of each.

GET/api/v1/channels

Channel 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.receivedSomeone replied to you on any channel.
message.status_changedA message was delivered, read or failed.
contact.opted_outA 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_inA contact gave marketing consent, with the same scope and state fields.
wallet.topup_succeededA top-up cleared and the balance went up.
wallet.debitedA campaign settled against the wallet.
wallet.low_balanceThe 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": [ … ] } }
400invalid_jsonThe body was not valid JSON.
401unauthorizedMissing, malformed or revoked API key.
404conversation_not_foundNo such record in your workspace.
422validation_failedThe body did not match the schema. issues lists each problem.
422unknown_segmentThe segmentId does not exist in your workspace.
429rate_limitedToo many requests. Back off and retry.
502paystack_errorThe 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.

See what each message costs →