SENDING · Published 2026-10-06 · Updated 2026-10-08 · SecureSMTP

Webhooks

Get email, unsubscribe, form and domain events posted to your server, and check the signature.

A webhook is a URL on your server that we POST to when something happens on your site: an email was sent, delivered, delayed, bounced, opened, clicked or reported as spam; a recipient unsubscribed; an email was not sent (suppressed, rejected, held or failed); a form was submitted; or a sending domain was verified. Webhooks are available on every plan.

Set up a webhook

  1. Open Webhooks in the dashboard (/app/forms/webhooks). Webhooks are set per site.
  2. Click Add webhook on the site, enter the Endpoint URL and tick the events you want (All also includes events added later). You can change them later with Edit events.
  3. Click Create webhook and save the signing secret it shows. It starts with whsec_. You can see it again later with Reveal.
  4. Click Send test to post a webhook.test event to your URL.

The URL can be http or https; use https. Each webhook shows how many deliveries succeeded and failed, and the last status. Pause stops deliveries until you click Resume. A site can have more than one webhook; each gets every event it is subscribed to.

Events

EventWhendata
email.sentWe accepted the email and handed it to our mail server.message_id, to, subject, mode
email.deliveredThe receiving mail server accepted the email.message_id, to, subject
email.bouncedThe receiving server refused the email for good.message_id, to, subject, detail, bounce_kind, suppressed
email.complainedThe recipient reported the email as spam.message_id (when known), to, subject, detail, suppressed (when known)
email.delayedThe receiving server asked us to try again later. We keep trying.message_id, to, subject, reason
email.openedThe email was opened (every open).message_id, to, subject
email.clickedA tracked link was clicked (every click).message_id, to, subject
email.unsubscribedA recipient unsubscribed from your site (first time only).to, source
email.suppressedNot sent: every recipient bounced, complained or unsubscribed before.to, subject, reason
email.rejectedNot sent: spam content or your plan’s limit.to, subject, reason
email.heldNot sent: a contact-form notification held as spam.to, subject, reason, confidence, detail
email.failedNot sent: the send attempt failed.to, subject, error
form.submittedA form submission was stored.submission_id, form_id, form_name, classification, spam_score, fields, referrer
form.spamA form submission was classified as spam.Same as form.submitted
domain.verifiedA sending domain passed its DNS check.domain, from_address, status
domain.unverifiedA verified domain failed a later check.domain, from_address, status
webhook.testYou clicked Send test.message, to

bounce_kind is recipient (the address does not exist), message (refused because of the content or the sending), transient (gave up after temporary errors) or unknown. Only recipient bounces can lead to suppression: see Bounces and suppressions.

Every field of every event, with examples, is in the event catalogue.

Payload

Every event is a JSON object with the same outer shape:

email.bounced
{
  "id": "7d1e4b2a-5f3c-4e8d-9a61-0c2b7f4e9d13",
  "type": "email.bounced",
  "created_at": "2026-10-07T09:15:22.481Z",
  "site_id": "0b8f5a52-6c1e-4d7a-9f43-2e5d8c71a9b0",
  "data": {
    "message_id": "<[email protected]>",
    "to": "[email protected]",
    "subject": "Your order has shipped",
    "detail": "550 5.1.1 The email account that you tried to reach does not exist",
    "bounce_kind": "recipient",
    "suppressed": false
  }
}
idstring (UUID)required
Unique per event. The same on every retry.
typestringrequired
The event name, for example email.bounced.
created_atstring (ISO 8601)required
When we created the event, in UTC.
site_idstring (UUID)required
The site the email was sent from.
dataobjectrequired
The event’s fields, listed in the table above. Fields with no value are left out.

To link an event to an email, compare data.message_id with the message_id from the POST /mail/send response.

Headers

HeaderValue
Content-Typeapplication/json
User-AgentSecureSMTP-Webhooks/1.0
X-QCS-Signaturesha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your secret
X-QCS-EventThe event name, the same as type in the body.
X-QCS-DeliveryA UUID for this delivery. The same on every retry. Not sent with webhook.test.

There is no timestamp header.

Verify the signature

  1. Read the request body as raw bytes, before any JSON parsing.
  2. Compute HMAC-SHA256 of those bytes with your whole secret, including the whsec_ prefix, as the key.
  3. Compare sha256= plus the hex result with X-QCS-Signature using a constant-time comparison. Reject the request if they differ.
// npm install express
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.SECURESSMTP_WEBHOOK_SECRET; // whsec_...
const seen = new Set(); // use your database in production

// Read the body as raw bytes: the signature is over the exact body we sent.
app.post('/webhooks/securessmtp', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
  const received = req.get('X-QCS-Signature') || '';
  const valid =
    received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!valid) return res.status(401).end();

  const event = JSON.parse(req.body.toString('utf8'));
  const deliveryId = req.get('X-QCS-Delivery') || event.id;
  if (seen.has(deliveryId)) return res.status(200).end(); // already handled
  seen.add(deliveryId);

  if (event.type === 'email.bounced') {
    console.log('bounced', event.data.to, event.data.bounce_kind, event.data.suppressed);
  }
  res.status(200).end();
});

app.listen(3000);
Parsing the JSON and serializing it again changes the bytes and breaks the signature. Always verify the raw body.

Retries

  • Answer with any 2xx status to accept the event. Anything else, or no answer within 10 seconds, is a failure.
  • We try 3 times in total: once, again after 1.5 seconds, and again after 3 more seconds. After the third failure the event is dropped. Events are not queued for later.
  • Answer quickly and do slow work afterwards, for example by putting the event on your own queue. If your server is down for longer than a few seconds, events from that time are lost; use the email log to catch up.
  • The test event is sent once, with no retries.

Duplicates and order

  • A retry can arrive even when your server handled the first try (for example, if you answered after 10 seconds). Store X-QCS-Delivery (or the body’s id) and skip events you have already handled.
  • Events can arrive in any order, and an email can be opened or clicked many times. Do not assume email.delivered comes before email.opened.