Webhooks
Receive real-time notifications about email events via webhooks. Track sends, deliveries, bounces, complaints, opens, clicks and more. Register an HTTPS endpoint, subscribe to the events you care about, and verify the signature on every request.
Available Events
Secret is Auto-Generated
If you don't provide a secret, we'll generate one for you. Save it - it's only shown once!
| Event | Description |
|---|---|
| email.sent | Message accepted by SendComms and handed to our mail infrastructure |
| email.delivered | Recipient's mail server accepted the message |
| email.bounced | Message was rejected by the recipient's mail server (hard or soft bounce) |
| email.complained | Recipient marked the message as spam |
| email.opened | Tracking pixel loaded — requires open tracking on the sending domain |
| email.clicked | A tracked link was clicked — requires click tracking on the sending domain |
| email.delivery_delayed | Delivery was temporarily deferred and is still being retried |
| email.failed | The send itself failed — the message never left our mail infrastructure |
| email.scheduled | A message was accepted for delivery at a future time |
| email.suppressed | Send was blocked because the address is on your suppression list |
| email.received | An inbound message arrived on one of your domains |
The same endpoint also accepts SMS, airtime and data events (sms.sent, sms.delivered, sms.failed, airtime.success, airtime.failed, data.purchased, data.delivered, data.success, data.failed), plus the wildcard "*" which subscribes to everything. Any name outside this list is rejected with 400 INVALID_EVENTS.
Select Language
# Register a webhook endpoint
curl -X POST \
https://api.sendcomms.com/api/v1/webhooks \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/webhooks/email",
"events": [
"email.sent",
"email.delivered",
"email.bounced",
"email.complained",
"email.opened",
"email.clicked",
"email.delivery_delayed",
"email.failed"
],
"secret": "your_webhook_secret"
}'Registration Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | Required | Your endpoint. Must be https:// — anything else returns 400 INVALID_URL |
| events | string[] | Required | Non-empty list of event names from the table above, or ["*"] for all of them. Unknown names return 400 INVALID_EVENTS |
| secret | string | Optional | Signing secret. If you omit it we generate a whsec_ value and return it once |
You have one webhook endpoint per account — registering again updates the existing URL, event list and secret rather than adding a second endpoint.
Webhook Payload
When an event occurs we POST a JSON body to your endpoint. Every payload has the same envelope — event, data, transaction_id and timestamp — with the fields inside data varying by event.
Delivery events (email.delivered, email.bounced)
{
"event": "email.delivered",
"data": {
"transaction_id": "email_mjgc0ejr_3ca715bfb7a0",
"type": "email",
"status": "delivered",
"email_id": "msg_8f21c0d4a97b",
"to": ["recipient@example.com"],
"subject": "Welcome to our platform!",
"detail": "",
"timestamp": "2026-08-23T10:30:04.512000+00:00"
},
"transaction_id": "email_mjgc0ejr_3ca715bfb7a0",
"timestamp": "2026-08-23T10:30:04.702000+00:00"
}Send events (email.sent, email.failed)
{
"event": "email.sent",
"data": {
"transaction_id": "email_mjgc0ejr_3ca715bfb7a0",
"type": "email",
"status": "sent",
"to": ["recipient@example.com"],
"subject": "Welcome to our platform!",
"email_id": "msg_8f21c0d4a97b",
"from": "Your App <hello@yourdomain.com>",
"cost": 0.01,
"error": null
},
"transaction_id": "email_mjgc0ejr_3ca715bfb7a0",
"timestamp": "2026-08-23T10:30:00.318000+00:00"
}transaction_idappears both in the envelope and insidedata, and matches the value returned by the send endpoint.email_idis the per-message id, so it also matches an entry in a batchresults[].- On
email.bounced,data.statusis"failed"anddata.detailcarries the reason reported by the receiving server. Always branch onevent, not ondata.status. - Treat
dataas open-ended — we add fields over time, so ignore anything you don't recognise.
Request Headers
| Header | Value |
|---|---|
| X-SendComms-Signature | sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with your webhook secret. Only sent when a secret is configured |
| Content-Type | application/json |
| User-Agent | SendComms-Webhook/1.0 |
Registration Response
When you register a webhook, you'll receive this response. Save the secret - it's only shown once!
{
"success": true,
"data": {
"id": "e406c83c-50bc-4783-b5fc-4beafe6bf5eb",
"url": "https://your-server.com/webhooks/email",
"events": [
"email.sent",
"email.delivered",
"email.bounced",
"email.complained",
"email.opened",
"email.clicked",
"email.delivery_delayed",
"email.failed"
],
"secret": "whsec_21be983f359112f9e07658ed2bddcee3...",
"active": true,
"created_at": "2026-08-23T23:25:12.006000+00:00"
}
}Verifying Webhooks
Every request is signed with your webhook secret. The signature is sha256= followed by the hex-encoded HMAC-SHA256 of the raw request body, sent in the X-SendComms-Signature header. Compute it over the bytes you received — re-serialising the parsed JSON will not reproduce the same digest.
// Node.js / Express — note express.raw(), not express.json()
const crypto = require('crypto');
app.post('/webhooks/sendcomms',
express.raw({ type: 'application/json' }),
(req, res) => {
const secret = process.env.SENDCOMMS_WEBHOOK_SECRET; // whsec_...
const received = req.headers['x-sendcomms-signature'] || '';
const expected = 'sha256=' +
crypto.createHmac('sha256', secret)
.update(req.body) // the raw Buffer, unmodified
.digest('hex');
const a = Buffer.from(received);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { event, data, transaction_id } = JSON.parse(req.body.toString());
console.log(`${event} for ${transaction_id}`);
// Acknowledge first, process asynchronously
res.status(200).json({ received: true });
}
);# Python / Flask
import hmac, hashlib, os
from flask import request, jsonify
@app.post('/webhooks/sendcomms')
def sendcomms_webhook():
secret = os.environ['SENDCOMMS_WEBHOOK_SECRET'].encode()
raw = request.get_data() # raw bytes, unmodified
expected = 'sha256=' + hmac.new(secret, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get('X-SendComms-Signature', '')):
return jsonify(error='Invalid signature'), 401
payload = request.get_json()
print(payload['event'], payload['transaction_id'])
return jsonify(received=True), 200Best Practices
Respond quickly
We wait up to 10 seconds for a response. Acknowledge with a 2xx immediately and process the event asynchronously.
Handle duplicates
One transaction produces several events, so deduplicate on the pair of transaction_id and event rather than transaction_id alone.
Use HTTPS
Webhook URLs must use HTTPS — an http:// URL is rejected with 400 INVALID_URL.
Verify signatures
Always verify X-SendComms-Signature against the raw body with a constant-time comparison to prevent spoofing.
Tolerate unknown events
Ignore event names and data fields you don't handle yet instead of erroring, so new events don't break your endpoint.
Managing Webhooks
List Your Webhooks
curl -X GET https://api.sendcomms.com/api/v1/webhooks \
-H "Authorization: Bearer YOUR_API_KEY"Delete a Webhook
curl -X DELETE "https://api.sendcomms.com/api/v1/webhooks?id=WEBHOOK_ID" \
-H "Authorization: Bearer YOUR_API_KEY"