Developers

One API for email, SMS and WhatsApp.

Send from your own software with a single HTTP request. South African law is enforced in the send path rather than left to you — consent, calling hours, and a working opt-out on every marketing message. Prepaid, in rand, per message.

BetaVideo generation, email and SMS are in early access

Comment campaigns are open to everyone today. The rest is rolling out through a beta — create a free account, apply inside the portal, and a person reviews every application. Approval lands in your inbox.

Join the beta

Send your first message

Create an account, then take your account id and an API key from Settings → API keys in the portal. A key is shown once and stored hashed, so copy it when it is created.

Every SDK is a thin wrapper over the same REST call, with the same seven resources under the same names. The cURL tab is the whole contract; the rest is ergonomics. All of them carry no runtime dependencies, so adding one to your project cannot start a version argument with the packages you already have.

curl -X POST https://api.connect24.co.za/v1/messages \
  -H "Authorization: Bearer $CONNECT24_API_KEY" \
  -H "X-Account-Id: $CONNECT24_ACCOUNT_ID" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-shipped" \
  -d '{
    "channel": "Sms",
    "to": "+27821234567",
    "content": { "text": "Your order has shipped." }
  }'

Authentication

Every request carries two headers. The key authenticates you; the account id says which account the call is for.

Authorization: Bearer ck_live_…
X-Account-Id: acc_3f9c1a7b4e2d

Keys are secret. Keep them on your server, never in a browser or a mobile app — anyone holding a key can send at your expense. Rotate a key from the portal and the old one stops working immediately.

Errors

Failures come back as a problem document with the reason in detail. The status is what your code should branch on, and the split is deliberate: anything in the 4xx range is yours to fix and will fail again unchanged, so an SDK must not retry it.

{
  "title": "Bad Request",
  "status": 402,
  "detail": "Insufficient credit."
}
StatusWhat it means
400The request is malformed. Repeating it unchanged will fail again.
401The key is wrong, revoked, or belongs to another account.
402Out of credit. Top up — retrying will not help.
403Not allowed. Removing a suppression the recipient created lands here.
404No such message, template, endpoint or domain on this account.
409A conflict — usually a name already taken.
429Rate limited. 600 requests a minute per account on /v1.
502The message could not be handed on for delivery. Safe to retry.

Messages

One endpoint sends on every channel. The shape does not change between email, SMS and WhatsApp — that sameness is the thing you are buying.

POST/v1/messages

Send a message

Accepted is not delivered. The response tells you the message was taken and charged; whether it arrived comes back later on a webhook. Pass an Idempotency-Key header when a network failure leaves you unsure whether a send went through — a repeat with the same key returns the original message rather than sending twice.

Headers

Idempotency-Keystring
Makes a retry safe. Use something stable and tied to the event, not a UUID generated at call time.

Body

channelstringrequired
Email, Sms or WhatsApp.
tostringrequired
Email address, or a phone number in E.164 (+27821234567).
contentobjectrequired
type plus the body: text for SMS, subject and html for email, templateName outside the WhatsApp window.
fromobject
address and optional name. Ignored until the domain is verified, when it becomes the Reply-To. Never set for SMS — South African traffic leaves from a shared originator.
templatestring
Name of a stored template to render instead of an inline body.
variablesobject
Values substituted into the template placeholders.
cc / bccstring[]
Email only.
replyTostring
Where replies go, when that is not the sender.
tagsstring[]
Your own labels, returned on the message and useful for filtering.
metadataobject
Key-value pairs stored with the message and never sent to the recipient.

Request body

{
  "channel": "Sms",
  "to": "+27821234567",
  "content": {
    "type": "text",
    "text": "Your order has shipped."
  },
  "tags": ["order-confirmation"],
  "metadata": { "orderId": "1042" }
}

Response · 202 Accepted

{
  "id": "msg_9f2c7a1b4e",
  "channel": "Sms",
  "status": "queued"
}
GET/v1/messages

List messages

Newest first. The same shape for every channel and both directions.

Query

channelstring
Email, Sms or WhatsApp. Omit for all.
limitinteger
Default 50, capped at 200.
offsetinteger
Default 0.

Response · 200 OK

[
  {
    "id": "msg_9f2c7a1b4e",
    "channel": "Sms",
    "direction": "outbound",
    "from": "Connect24",
    "fromName": null,
    "to": "+27821234567",
    "status": "delivered",
    "content": {
      "type": "text",
      "text": "Your order has shipped.",
      "subject": null,
      "html": null,
      "templateName": null,
      "mediaUrls": null
    },
    "failureReason": null,
    "createdAt": "2026-08-30T09:14:02.114Z",
    "updatedAt": "2026-08-30T09:14:07.902Z",
    "charged": "R0.34",
    "chargedMicros": 340000,
    "tags": ["order-confirmation"],
    "isTest": false,
    "providerMessageId": "0200000..."
  }
]
GET/v1/messages/{id}

Retrieve a message

Use this to answer "what happened to that one". failureReason carries the reason the network gave, when status is failed.

Path

idstringrequired
The id returned when the message was accepted.

Response · 200 OK

{
  "id": "msg_9f2c7a1b4e",
  "channel": "Sms",
  "direction": "outbound",
  "from": "Connect24",
  "to": "+27821234567",
  "status": "failed",
  "content": { "type": "text", "text": "Your order has shipped." },
  "failureReason": "Handset unreachable",
  "createdAt": "2026-08-30T09:14:02.114Z",
  "updatedAt": "2026-08-30T09:14:31.006Z",
  "charged": "R0.34",
  "chargedMicros": 340000,
  "tags": [],
  "isTest": false,
  "providerMessageId": "0200000..."
}

Templates

Stored bodies with placeholders, so copy lives on the platform rather than in your deploy. A name is unique per account and is what the send API refers to, which is why renaming is not the same as editing.

GET/v1/templates

List templates

Query

limitinteger
Default 100.

Response · 200 OK

[
  {
    "id": "tpl_4a91c0",
    "name": "payment_reminder",
    "subject": "Payment due, {{name}}",
    "html": "<p>Hi {{name}}, {{amount}} is due on {{date}}.</p>",
    "text": null,
    "version": 3,
    "createdAt": "2026-07-02T08:00:00.000Z",
    "updatedAt": "2026-08-21T11:42:10.551Z"
  }
]
POST/v1/templates

Create a template

Placeholders are written {{name}} and filled at send time. One with no matching variable is left as-is rather than blanked, so a missing value shows up in a test send instead of silently going out as an empty sentence.

Body

namestringrequired
Unique per account. This is what send refers to.
subjectstring
Email only.
htmlstring
The body, for email.
textstring
Plain-text body, used for SMS and as the email fallback.

Request body

{
  "name": "payment_reminder",
  "subject": "Payment due, {{name}}",
  "html": "<p>Hi {{name}}, {{amount}} is due on {{date}}.</p>"
}

Response · 201 Created

{
  "id": "tpl_4a91c0",
  "name": "payment_reminder",
  "subject": "Payment due, {{name}}",
  "html": "<p>Hi {{name}}, {{amount}} is due on {{date}}.</p>",
  "text": null,
  "version": 1,
  "createdAt": "2026-08-30T09:20:00.000Z",
  "updatedAt": "2026-08-30T09:20:00.000Z"
}
PUT/v1/templates/{id}

Update a template

Bumps the version. That is what makes editing safe: a message already sent stays traceable to the body that produced it, rather than appearing to have said whatever the template says today.

Path

idstringrequired
The template id.

Request body

{
  "subject": "Your payment is due, {{name}}",
  "html": "<p>Hi {{name}}, {{amount}} is due on {{date}}.</p>"
}

Response · 200 OK

{
  "id": "tpl_4a91c0",
  "name": "payment_reminder",
  "version": 2,
  "updatedAt": "2026-08-30T09:31:44.210Z"
}
DELETE/v1/templates/{id}

Delete a template

Messages already sent with it are unaffected — they carry the body they were sent with.

Path

idstringrequired
The template id.

Response · 204 No Content

(empty)

Suppressions

Addresses that will not be sent to. Two kinds live here and they behave differently: one the recipient created cannot be removed by you, through this API or any other route.

GET/v1/suppressions

List suppressions

Query

limitinteger
Default 100.

Response · 200 OK

[
  {
    "address": "someone@example.com",
    "reason": "Unsubscribed",
    "detail": "Clicked the unsubscribe link",
    "createdAt": "2026-08-11T06:31:00.000Z"
  }
]
POST/v1/suppressions

Add a suppression

For somebody who asked you directly rather than through an unsubscribe.

Body

addressstringrequired
Email address or phone number.
reasonstring
Your own note, for when you look at this in six months.

Request body

{
  "address": "someone@example.com",
  "reason": "Asked by phone"
}

Response · 201 Created

{
  "address": "someone@example.com",
  "reason": "Manual",
  "detail": "Asked by phone",
  "createdAt": "2026-08-30T09:40:00.000Z"
}
DELETE/v1/suppressions/{address}

Remove a suppression

Refused with 403 when the recipient created it — an unsubscribe, a STOP reply, a complaint, or the National Opt-Out Registry. That is not a bug to work around: acting on it would mean messaging somebody who said no.

Path

addressstringrequired
URL-encoded.

Response · 204 No Content

(empty)

Webhooks

Delivery is asynchronous, so this is how you learn what happened. Verify every request before acting on it — your URL is public, and without verification anyone can tell your system a message bounced.

GET/v1/webhooks

List endpoints

Response · 200 OK

[
  {
    "id": "we_71b0c4",
    "url": "https://acme.co.za/hooks/connect24",
    "secret": "whsec_...",
    "events": ["message.delivered", "message.failed"],
    "isActive": true,
    "consecutiveFailures": 0,
    "createdAt": "2026-08-02T10:00:00.000Z",
    "lastDeliveryAt": "2026-08-30T09:14:08.001Z"
  }
]
POST/v1/webhooks

Register an endpoint

The signing secret is on the response and is shown once. Store it now; it cannot be read back later, only replaced.

Body

urlstringrequired
HTTPS. Answer quickly — anything that is not 2xx is retried.
eventsstring[]
Omit for all events.

Request body

{
  "url": "https://acme.co.za/hooks/connect24",
  "events": ["message.delivered", "message.failed"]
}

Response · 201 Created

{
  "id": "we_71b0c4",
  "url": "https://acme.co.za/hooks/connect24",
  "secret": "whsec_9c1f...",
  "events": ["message.delivered", "message.failed"],
  "isActive": true,
  "consecutiveFailures": 0,
  "createdAt": "2026-08-30T09:45:00.000Z",
  "lastDeliveryAt": null
}
DELETE/v1/webhooks/{endpointId}

Delete an endpoint

Path

endpointIdstringrequired
The endpoint id.

Response · 204 No Content

(empty)
GET/v1/webhooks/deliveries

List delivery attempts

The first place to look when events stop arriving. statusCode and error are what your endpoint returned.

Query

limitinteger
Default 100.

Response · 200 OK

[
  {
    "id": "whd_2f8a",
    "endpointId": "we_71b0c4",
    "event": "message.delivered",
    "messageId": "msg_9f2c7a1b4e",
    "status": "failed",
    "attempts": 3,
    "statusCode": 500,
    "error": "Internal Server Error",
    "createdAt": "2026-08-30T09:14:08.001Z",
    "deliveredAt": null
  }
]

Sending domains

Until a domain is verified, email leaves from your account’s assigned address on connect24.co.za and yours becomes the Reply-To. That assigned address is random and not chooseable: if customers could pick it, one could send as security@connect24.co.za and phish under the platform’s brand.

GET/v1/sending-domains

List domains

Response · 200 OK

[
  {
    "domain": "acme.co.za",
    "status": "verified",
    "dkimVerified": true,
    "ownershipVerified": true,
    "detail": null,
    "records": [],
    "createdAt": "2026-08-04T07:00:00.000Z",
    "verifiedAt": "2026-08-04T07:22:14.000Z"
  }
]
POST/v1/sending-domains

Add a domain

Returns the DNS records to publish. Up to 25 domains per account.

Body

domainstringrequired
The domain you send as, e.g. acme.co.za.

Request body

{ "domain": "acme.co.za" }

Response · 201 Created

{
  "domain": "acme.co.za",
  "status": "pending",
  "dkimVerified": false,
  "ownershipVerified": false,
  "detail": "Publish the records below, then call verify.",
  "records": [
    {
      "type": "TXT",
      "name": "_connect24.acme.co.za",
      "value": "connect24-verify=9c1f...",
      "required": true,
      "purpose": "Proves you control the domain"
    },
    {
      "type": "CNAME",
      "name": "c24a._domainkey.acme.co.za",
      "value": "c24a.dkim.connect24.co.za",
      "required": true,
      "purpose": "DKIM signing"
    }
  ],
  "createdAt": "2026-08-30T09:50:00.000Z",
  "verifiedAt": null
}
POST/v1/sending-domains/{domain}/verify

Verify a domain

Checks the records you published. DNS propagation is not instant, so a first call that comes back unverified usually means "not yet" rather than "wrong" — wait and call again.

Path

domainstringrequired
The domain to check.

Response · 200 OK

{
  "domain": "acme.co.za",
  "status": "verified",
  "dkimVerified": true,
  "ownershipVerified": true,
  "detail": null,
  "records": [],
  "createdAt": "2026-08-30T09:50:00.000Z",
  "verifiedAt": "2026-08-30T10:06:31.884Z"
}
DELETE/v1/sending-domains/{domain}

Remove a domain

Mail then leaves from your assigned address again.

Path

domainstringrequired
The domain to remove.

Response · 204 No Content

(empty)

Account and billing

Prepaid, in rand, charged per message. Every message is charged before it is sent — when the balance cannot cover one the send is refused with 402 rather than silently dropped, so a low balance surfaces as an error you can act on instead of as messages quietly not arriving.

GET/v1/account

Retrieve your account

Response · 200 OK

{
  "accountId": "acc_3f9c1a7b4e2d",
  "name": "Acme (Pty) Ltd",
  "createdAt": "2026-06-18T12:00:00.000Z"
}
GET/v1/channels

Which channels can send

Worth calling at start-up in a deployment you did not configure yourself: it answers "why is nothing sending" without waiting for a failed message to tell you.

Response · 200 OK

[
  { "channel": "Email", "available": true },
  { "channel": "Sms", "available": true },
  { "channel": "WhatsApp", "available": false }
]
GET/v1/balance

Credit remaining

buysEmails and buysLocalSms are what the balance actually buys at today’s rates, which is the number people want and the one they otherwise compute wrongly.

Response · 200 OK

{
  "balance": "R241.80",
  "balanceMicros": 241800000,
  "buysEmails": 12090,
  "buysLocalSms": 711,
  "pricing": [
    { "channel": "Email", "price": "R0.02", "priceMicros": 20000, "region": null },
    { "channel": "Sms", "price": "R0.34", "priceMicros": 340000, "region": "ZA" }
  ],
  "isFreeCredit": false
}
GET/v1/ledger

Every credit and debit

What caused each movement, and the balance after it. A message that is refused after we have charged for it is refunded here automatically.

Query

limitinteger
Default 50.

Response · 200 OK

[
  {
    "id": "led_88c1",
    "type": "debit",
    "amount": "-R0.34",
    "amountMicros": -340000,
    "balanceAfter": "R241.46",
    "reason": "Sms to +27821234567",
    "messageId": "msg_9f2c7a1b4e",
    "platformFunded": false,
    "createdAt": "2026-08-30T09:14:02.114Z"
  }
]

What the send path enforces

Most providers hand you an endpoint and leave the law to you. These rules are ours to know, so they are applied where a message is actually sent — not in a checklist you are asked to read.

POPIA consent, per contact

A lawful basis is recorded against each contact, along with where you got their details. Contacts without one are left out of marketing campaigns rather than silently included.

Consumer Protection Act hours

No marketing on Sundays or public holidays, Saturdays only 09:00–13:00, weekdays 08:00–20:00. Press send at ten on a Sunday night and the campaign waits until Monday morning.

A working opt-out, every time

The WASPA Code requires one on every marketing message. It is appended on the way out, counted in the cost estimate, and honoured across every list permanently once used.

Segment counting that matches the carrier

One emoji turns a 160-character GSM-7 message into 70-character UCS-2 parts. The cost is computed the way the network computes it, and shown before you send rather than on the invoice.

Connect24 enforces these rules in the send path. You remain the responsible party under POPIA for the data you upload and the consent you hold — see the Terms.

Delivery webhooks

Delivery is asynchronous: an accepted message is not a delivered one. Register an endpoint and we post every state change to it.

POST /your/webhook
X-Connect24-Signature: t=1724832000,v1=…

{ "id": "evt_…", "type": "message.delivered", "messageId": "msg_…" }

Verify the signature against the raw request body, before any framework parses it — a re-serialised body will not match. Delivery is at-least-once, so deduplicate on the event id, and answer quickly: anything that is not a 2xx is retried.

Sending as your own domain

Until you verify a domain, email leaves from your account’s assigned address on connect24.co.za and the address you set becomes the Reply-To. Add your domain, publish the DNS records we return, and verify — then mail goes out as you.

The assigned address is random and not chooseable, deliberately: if customers could pick it, one could send as security@connect24.co.za and phish under our brand. The reputation of the sending domain is ours, so the address has to be too.

Questions

What is a messaging API?
A messaging API lets your own software send email, SMS or WhatsApp messages by making an HTTP request, instead of a person sending them by hand. You send a request naming the recipient and the message; we deliver it and report back whether it arrived.
When may I send marketing SMS in South Africa?
The Consumer Protection Act bars direct marketing on Sundays and public holidays, restricts Saturdays to 09:00–13:00, and otherwise allows 08:00–20:00 on weekdays. Connect24 enforces these hours in the send path: a campaign submitted outside the window waits until it reopens rather than sending.
Does POPIA apply to SMS and email marketing?
Yes. POPIA requires a lawful basis before you market to someone, and section 69 sets out consent requirements for electronic direct marketing. Connect24 records consent and where you got the details on each contact, and leaves contacts without a lawful basis out of marketing campaigns.
Do I have to include an opt-out in every marketing message?
Yes. The WASPA Code requires a working opt-out on every marketing message — by replying STOP where that is technically possible, and clear instructions in the body where it is not. Connect24 appends this automatically and counts it in the cost estimate before you send.
How is an SMS charged if it contains an emoji?
An SMS holds 160 characters using the GSM-7 alphabet. A single emoji, curly quote or em dash switches the whole message to UCS-2, which holds only 70 characters per part — so a 150-character message can go from one SMS to three. Connect24 counts segments the way the carrier does and shows the cost while you write.
What does it cost?
Prepaid, in rand, charged per message. There is no monthly minimum and no per-seat fee. Every message records what it cost, whether it was delivered, and why it was not.

Stuck on something?

A real person answers. Include the message id or account id and you will usually get a useful reply rather than a request for more information.

support@connect24.co.za · replies to the address on your account