We use essential cookies to make the site work. With your consent, we also use analytics + marketing cookies.
Every state change on your workspace can push a signed event to your URL. Signing follows the Stripe convention — HMAC-SHA256 over {timestamp}.{payload} — and deliveries are retried for up to 24 hours before landing in a per-endpoint dead-letter queue.
Subscribe to any subset from the dashboard. Payload shape is stable and versioned by event.api_version.
| Type | Description |
|---|---|
| message.sent | Message accepted by the upstream provider. |
| message.delivered | Provider confirmed delivery to the recipient device. |
| message.failed | Message failed after all provider retries — includes reason code. |
| message.read | Recipient opened / read the message (channel-dependent). |
| message.replied | Inbound reply on the same conversation thread. |
| message.expired | TTL elapsed before the provider could deliver. |
| conversation.assigned | A conversation was routed to a team member or bot. |
| conversation.closed | A conversation was closed by an agent or automation. |
| campaign.completed | Bulk campaign finished — includes send / deliver / fail counts. |
| provider.status | Provider health degradation or restoration. |
| contact.updated | Contact record changed — created, edited, or opted-out. |
| number.provisioned | Purchase or port-in completed and the number is live. |
| template.approved | A submitted template was approved by the channel provider. |
| template.rejected | Template review failed — includes review notes. |
| invoice.finalized | Monthly invoice finalized and ready for payment. |
Each request carries a CPaaS-Signature header (hex) and a CPaaS-Timestamp (unix seconds). Compute the HMAC over {timestamp}.{raw_body}. Use a constant-time compare and reject stale timestamps to prevent replay.
import crypto from 'crypto';
import express from 'express';
const app = express();
const SIGNING_SECRET = process.env.CPAAS_WEBHOOK_SECRET!;
app.post(
'/webhooks/cpaas',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.header('CPaaS-Signature') ?? '';
const timestamp = req.header('CPaaS-Timestamp') ?? '';
const payload = req.body.toString('utf8');
const skew = Math.abs(Date.now() / 1000 - Number(timestamp));
if (skew > 300) return res.status(400).send('stale timestamp');
const expected = crypto
.createHmac('sha256', SIGNING_SECRET)
.update(`${timestamp}.${payload}`)
.digest('hex');
const ok = crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex'),
);
if (!ok) return res.status(400).send('invalid signature');
const event = JSON.parse(payload);
handleEvent(event);
res.status(200).send('ok');
},
);A non-2xx response or timeout after 10 seconds triggers a retry with exponential backoff and jitter. After 8 failed attempts (~24 hours) the delivery lands in the endpoint's dead-letter queue where it can be replayed from the dashboard.
| Attempt | Delay after previous |
|---|---|
| 1 | immediate |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 4 hours |
| 7 | 12 hours |
| 8 | 24 hours (final) |
Use the built-in tester to send synthetic events with a real signature to your endpoint — inspect deliveries, response bodies, and timing without touching production data.
Open the webhook testerStore the event.id from every request. If you have processed it, reply 200 immediately without doing the work again.
Reply within 10 seconds. Do heavy work asynchronously — enqueue the event to a job runner before returning 200.
Return 5xx only for transient issues you want retried. Return 2xx for events you deliberately ignore.
Reject before parsing when the signature or timestamp is invalid. Never trust the payload until it is verified.
Each endpoint supports two signing secrets so you can roll without downtime. Issue a secondary secret, deploy your service to accept either, promote the secondary to primary, and remove the old one when you have confirmed no traffic is using it.