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
- Open Webhooks in the dashboard (
/app/forms/webhooks). Webhooks are set per site. - 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.
- Click Create webhook and save the signing secret it shows. It starts with
whsec_. You can see it again later with Reveal. - Click Send test to post a
webhook.testevent 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
| Event | When | data |
|---|---|---|
email.sent | We accepted the email and handed it to our mail server. | message_id, to, subject, mode |
email.delivered | The receiving mail server accepted the email. | message_id, to, subject |
email.bounced | The receiving server refused the email for good. | message_id, to, subject, detail, bounce_kind, suppressed |
email.complained | The recipient reported the email as spam. | message_id (when known), to, subject, detail, suppressed (when known) |
email.delayed | The receiving server asked us to try again later. We keep trying. | message_id, to, subject, reason |
email.opened | The email was opened (every open). | message_id, to, subject |
email.clicked | A tracked link was clicked (every click). | message_id, to, subject |
email.unsubscribed | A recipient unsubscribed from your site (first time only). | to, source |
email.suppressed | Not sent: every recipient bounced, complained or unsubscribed before. | to, subject, reason |
email.rejected | Not sent: spam content or your plan’s limit. | to, subject, reason |
email.held | Not sent: a contact-form notification held as spam. | to, subject, reason, confidence, detail |
email.failed | Not sent: the send attempt failed. | to, subject, error |
form.submitted | A form submission was stored. | submission_id, form_id, form_name, classification, spam_score, fields, referrer |
form.spam | A form submission was classified as spam. | Same as form.submitted |
domain.verified | A sending domain passed its DNS check. | domain, from_address, status |
domain.unverified | A verified domain failed a later check. | domain, from_address, status |
webhook.test | You 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.
Payload
Every event is a JSON object with the same outer shape:
{
"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
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | SecureSMTP-Webhooks/1.0 |
X-QCS-Signature | sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your secret |
X-QCS-Event | The event name, the same as type in the body. |
X-QCS-Delivery | A UUID for this delivery. The same on every retry. Not sent with webhook.test. |
There is no timestamp header.
Verify the signature
- Read the request body as raw bytes, before any JSON parsing.
- Compute HMAC-SHA256 of those bytes with your whole secret, including the
whsec_prefix, as the key. - Compare
sha256=plus the hex result withX-QCS-Signatureusing 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);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’sid) 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.deliveredcomes beforeemail.opened.