SendComms
Email API

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!

EventDescription
email.sentMessage accepted by SendComms and handed to our mail infrastructure
email.deliveredRecipient's mail server accepted the message
email.bouncedMessage was rejected by the recipient's mail server (hard or soft bounce)
email.complainedRecipient marked the message as spam
email.openedTracking pixel loaded — requires open tracking on the sending domain
email.clickedA tracked link was clicked — requires click tracking on the sending domain
email.delivery_delayedDelivery was temporarily deferred and is still being retried
email.failedThe send itself failed — the message never left our mail infrastructure
email.scheduledA message was accepted for delivery at a future time
email.suppressedSend was blocked because the address is on your suppression list
email.receivedAn 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 WEBHOOK
# 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

ParameterTypeRequiredDescription
urlstringRequiredYour endpoint. Must be https:// — anything else returns 400 INVALID_URL
eventsstring[]RequiredNon-empty list of event names from the table above, or ["*"] for all of them. Unknown names return 400 INVALID_EVENTS
secretstringOptionalSigning 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_id appears both in the envelope and inside data, and matches the value returned by the send endpoint.
  • email_id is the per-message id, so it also matches an entry in a batch results[].
  • On email.bounced, data.status is "failed" and data.detail carries the reason reported by the receiving server. Always branch on event, not on data.status.
  • Treat data as open-ended — we add fields over time, so ignore anything you don't recognise.

Request Headers

HeaderValue
X-SendComms-Signaturesha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with your webhook secret. Only sent when a secret is configured
Content-Typeapplication/json
User-AgentSendComms-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), 200

Best 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"