Webhooks
Hear about deliveries, new customers and more as they happen.
Webhooks tell your own systems when something happens in Goomzo, so they don't have to keep asking. Register a URL, choose the events it hears, and Goomzo sends each one there as it happens.
Add an endpoint
- Open Settings → Webhooks and select New endpoint. Only the organization's owner and admins can.
- Enter the URL that will receive events. It must be
https://and reachable from the internet. - Choose the events it hears. Choose at least one.
- Open the endpoint and select Send test event to check that your receiver works.
An organization can have up to 10 endpoints. Turn one off to pause it: its events wait and are sent when it's turned on again.
You can also manage endpoints through the API, with a service account that has the webhooks.manage
permission:
curl https://api.goomzo.africa/v1/webhook-endpoints \
-H "Authorization: Bearer $GOOMZO_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/webhooks/goomzo", "events": ["message.delivered", "message.failed"]}'The answer includes the endpoint's secret, which you'll use to check signatures.
Events
| Event | When |
|---|---|
message.sent | The gateway took a message. |
message.delivered | A message reached the customer's phone. |
message.failed | A message couldn't be sent or delivered. error says why. |
broadcast.finished | A broadcast finished going out, or couldn't. Includes its counts. |
customer.created | A customer was added, one by one, through the API or by import. |
sms.balance_low | Fewer than 100 SMS are left. Sent once each time it falls below. |
webhook.test | Only when you select Send test event. |
What arrives
Each event is a POST with a JSON body:
{
"id": "evt_4hq2v9n1c8x0t6pk",
"type": "message.delivered",
"createdAt": "2026-11-08T09:30:00Z",
"organizationId": "org_...",
"data": {
"id": "msg_...",
"broadcastId": null,
"customerId": "cus_...",
"channel": "sms",
"recipient": "+255712345678",
"status": "delivered",
"error": null,
"cost": 1,
"sentAt": "2026-11-08T09:29:58Z",
"deliveredAt": "2026-11-08T09:30:00Z",
"failedAt": null,
"test": false
}
}data depends on the event:
- Messages:
id,broadcastId,customerId,channel,recipient,status,error,cost,sentAt,deliveredAt,failedAtandtest. broadcast.finished:id,name,channel,status(sentorfailed),error,recipients,sent,delivered,failedandtest.customer.created:id,name,phone,emailandattributes.sms.balance_low:leftandthreshold.
These headers come with it:
| Header | Value |
|---|---|
Goomzo-Event-Id | The event's id |
Goomzo-Event-Type | The event's type |
Goomzo-Delivery-Id | This delivery, as shown in the log |
Goomzo-Signature | t=<unix seconds>,v1=<signature> |
Check the signature
Anyone can post to your URL, so check that each event is from Goomzo before you act on it. v1 is a hex
HMAC-SHA256 of the time t, a dot, and the raw body, made with the endpoint's secret.
- Read
tandv1from theGoomzo-Signatureheader. - Compute the signature from
t + "." + body, using the body exactly as it arrived, before parsing it. - Accept the event only if the two match and
tis within 5 minutes of now.
import crypto from 'node:crypto'
function isFromGoomzo(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ''))
}import hashlib, hmac, time
def is_from_goomzo(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) < 300
return fresh and hmac.compare_digest(expected, parts.get("v1", ""))The secret starts with whsec_. Find it in the endpoint under Signing secret.
Answer quickly
Answer with any 2xx status within 10 seconds. Do the slow work afterwards, for example in a queue.
Anything else counts as a failure: another status, a timeout, or no answer. Redirects aren't followed, so use the final URL. A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, then 2, 5, 10 and 20 hours. After 8 tries, about a day and a half, it's marked failed.
The same event can arrive more than once, and events can arrive out of order. Use the event id to skip ones
you've already handled, and the timestamps in data to tell which came last.
The delivery log
Open an endpoint to see its deliveries from the last 30 days: what was sent, how many tries it took, and what your server answered. Select one to see its full body and your server's response, and select Resend to send it again.
Through the API, list an endpoint's deliveries with GET /v1/webhook-endpoints/{id}/deliveries, and send one
again with POST /v1/webhook-deliveries/{id}/resend.