Adding an endpoint
In the dashboard go to Developers, Webhooks, add your HTTPS URL and tick the events you want. We show the signing secret once, when you create the endpoint; keep it with your other secrets. You can rotate it later, and send a test event to check everything is wired up. Through the API it's POST /webhooks, and POST /webhooks/{id}/test sends a test event.
An account can have up to 10 endpoints. The URL has to be reachable on the public internet: we refuse addresses on private networks, and we don't follow redirects.
Events
| Event | When it fires |
|---|---|
message.sent | A text has left us for the network. Test texts get this too, about a second after you send. |
message.delivered | The network confirmed the phone received it. |
message.failed | The text failed before it left us (refunded), or the network could not deliver it (status undelivered, not refunded). Check data.status and data.error_code. |
message.received | Someone texted one of your numbers. data is the inbound message, including body and from. |
opt_out.created | A number texted STOP or a similar keyword and is now on your opt-out list. |
link.clicked | Someone tapped a tracked link. data carries the link, its destination, the message and campaign it was in, the recipient number and the click count. |
The message events carry the same message object the API returns from GET /messages/{id}, so client_ref comes back to you on every one. That's the easy way to match an event to a booking or order in your own system.
What we send
A POST with a JSON body and three headers. X-Connect-Event names the event, X-Connect-Delivery identifies this delivery (it stays the same on retries), and X-Connect-Signature carries the signature.
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
X-Connect-Event: message.delivered
X-Connect-Delivery: whd_01J9Z8X7W6V5T4S3R2Q1P0N9MA
X-Connect-Signature: t=1760605206,v1=5f2b9c...
{
"id": "evt_01J9Z8X7W6V5T4S3R2Q1P0N9M9",
"type": "message.delivered",
"created_at": "2026-10-16T09:00:06.000Z",
"data": {
"id": "msg_01J9Z8X7W6V5T4S3R2Q1P0N9M8",
"direction": "outbound",
"status": "delivered",
"to": "+447412000001",
"segments": 1,
"credits": 1,
"client_ref": "booking-1042",
"test": false,
"delivered_at": "2026-10-16T09:00:06.000Z"
}
}{
"id": "evt_01J9Z8X7W6V5T4S3R2Q1P0N9MB",
"type": "opt_out.created",
"created_at": "2026-10-16T09:14:10.000Z",
"data": { "phone": "+447412000001", "source": "keyword", "note": null, "created_at": "2026-10-16T09:14:10.000Z" }
}Checking the signature
The signature header looks like t=1760605206,v1=5f2b.... t is when we signed it (Unix seconds) and v1 is the HMAC SHA-256, in hex, of the string <t>.<raw body> using your endpoint's secret. To check it, work out the same HMAC over the body exactly as it arrived (before your framework parses it), compare the two in constant time, and turn away anything signed more than five minutes ago.
import crypto from 'node:crypto';
// rawBody is the request body exactly as it arrived, before any JSON parsing.
export function isFromCaptiFiConnect(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > 300) return false; // more than five minutes out
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib, hmac, time
def is_from_captifi_connect(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > 300: # more than five minutes out
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))<?php
function isFromCaptiFiConnect(string $rawBody, string $signatureHeader, string $secret): bool
{
$parts = [];
foreach (explode(',', $signatureHeader) as $pair) {
[$k, $v] = array_pad(explode('=', $pair, 2), 2, '');
$parts[$k] = $v;
}
$t = (int) ($parts['t'] ?? 0);
if ($t === 0 || abs(time() - $t) > 300) {
return false; // missing, or more than five minutes out
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1'] ?? '');
}
// $rawBody = file_get_contents('php://input');Answering and retries
Reply with any 2xx status within 8 seconds and we count the delivery as done. Do the slow work afterwards, in a queue or a background job. Anything else (a timeout, a 4xx or 5xx, a refused connection) is a failure, and we try again after 1, 5, 30, 120, 360 and 720 minutes, then give up on that event.
If an endpoint fails 100 times in a row, we switch it off and it shows as Off in the dashboard. Fix whatever was wrong, switch it back on, and new events flow again. Recent deliveries, with their status, attempts and the last status code or error, are listed under each endpoint and at GET /webhooks/{id}/deliveries.
Duplicates and order
A retry can arrive after your server did handle the first attempt (say it answered a fraction too late), so store the X-Connect-Delivery value, or the event id, and skip any you've seen. Events can also arrive out of order after a retry. Use the message's status and timestamps to decide the latest state, and don't let a late message.sent overwrite a delivered.
Want to try it without spending credit? Test keys fire real, signed webhooks for texts that never leave.