Webhooks
Receive delivery, bounce, complaint, and click events at an HTTPS endpoint of your choosing. HMAC-SHA256 signed; retried on 5xx; replay protection out of the box. The signature shape is intentionally close to Stripe's so reference code translates with minimal change.
Register an endpoint
One endpoint receives events for all your sending domains. Register multiple if you want fan-out (e.g. one for production analytics, one for an internal Slack notifier). Endpoint management lives on the REST API page; this guide focuses on receiving.
Event types
| Type | When |
|---|---|
email.accepted | API accepted the submission. |
email.queued | Message persisted to the sender queue. |
email.delivered | Receiving MTA returned 2xx on DATA. |
email.soft_bounced | Transient SMTP failure (4xx) — will be retried. |
email.hard_bounced | Permanent failure (5xx or async DSN). Recipient is added to the suppression list. |
email.complained | Recipient marked the message as spam (FBL report). Suppressed. |
email.failed | Exhausted all retries. |
email.opened | Tracking pixel was fetched. |
email.clicked | A tracked link was followed. |
email.unsubscribed | One-click List-Unsubscribe was actioned. |
Payload shape
{
"id": "f47ac10b-58cc-...",
"type": "email.delivered",
"occurred_at": "2026-05-26T09:07:42.123Z",
"message_id": "8a3f1f5b-d2c5-...",
"smtp_code": 250,
"detail": { ... }
}
Three headers are set on every POST:
| Header | Value |
|---|---|
X-Qwicmail-Event | Event type, e.g. email.delivered. |
X-Qwicmail-Delivery-Id | Per-delivery UUID. Re-sent unchanged on retries — use it to dedupe. |
X-Qwicmail-Signature | Signed timestamp + body. See below. |
Verifying signatures
The X-Qwicmail-Signature header is a comma-separated list of a Unix timestamp and a hex-encoded HMAC-SHA256:
X-Qwicmail-Signature: t=1748246862,v1=8e5a3...c91d
The signed payload is "<timestamp>.<raw-body>". To verify:
- Parse the header into
tandv1. - Reject if
|now - t|exceeds your tolerance (recommended: 5 minutes — protects against replay). - Compute
hex(HMAC-SHA256(secret, "<t>." + raw_body)). - Compare with
v1using a constant-time comparator.
Use the raw bytes of the request body — re-marshalling JSON will change whitespace and break the signature.
import express from "express";
import { Qwicmail } from "@qwicmail/sdk";
const qm = new Qwicmail({ apiKey: process.env.QWICMAIL_API_KEY! });
const app = express();
// Important: receive the raw body, NOT the parsed JSON object.
app.post("/hooks/qwicmail",
express.raw({ type: "application/json" }),
(req, res) => {
try {
const evt = qm.webhooks.verify({
body: req.body, // Buffer
signature: req.header("X-Qwicmail-Signature")!,
secret: process.env.QWICMAIL_WEBHOOK_SECRET!,
toleranceSeconds: 300,
});
// evt is the typed event payload
console.log(evt.type, evt.message_id);
res.sendStatus(200);
} catch (e) {
res.sendStatus(400);
}
});from flask import Flask, request, abort
from qwicmail import Qwicmail, WebhookVerificationError
qm = Qwicmail(api_key=os.environ["QWICMAIL_API_KEY"])
app = Flask(__name__)
@app.post("/hooks/qwicmail")
def hook():
try:
evt = qm.webhooks.verify(
body=request.get_data(), # raw bytes
signature=request.headers["X-Qwicmail-Signature"],
secret=os.environ["QWICMAIL_WEBHOOK_SECRET"],
tolerance_seconds=300,
)
except WebhookVerificationError:
abort(400)
print(evt.type, evt.message_id)
return "", 200http.HandleFunc("/hooks/qwicmail", func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read", http.StatusBadRequest)
return
}
evt, err := qm.Webhooks.Verify(qwicmail.VerifyParams{
Body: raw,
Signature: r.Header.Get("X-Qwicmail-Signature"),
Secret: os.Getenv("QWICMAIL_WEBHOOK_SECRET"),
ToleranceSeconds: 300,
})
if err != nil {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
log.Println(evt.Type, evt.MessageID)
w.WriteHeader(http.StatusOK)
})require "sinatra"
require "qwicmail"
qm = Qwicmail::Client.new(api_key: ENV.fetch("QWICMAIL_API_KEY"))
post "/hooks/qwicmail" do
raw = request.body.read
begin
evt = qm.webhooks.verify(
body: raw,
signature: request.env["HTTP_X_QWICMAIL_SIGNATURE"],
secret: ENV.fetch("QWICMAIL_WEBHOOK_SECRET"),
tolerance_seconds: 300,
)
rescue Qwicmail::WebhookVerificationError
halt 400
end
puts "#{evt.type} #{evt.message_id}"
status 200
end<?php
use Qwicmail\Client;
use Qwicmail\WebhookVerificationError;
$qm = new Client(getenv("QWICMAIL_API_KEY"));
$raw = file_get_contents("php://input");
try {
$evt = $qm->webhooks->verify([
"body" => $raw,
"signature" => $_SERVER["HTTP_X_QWICMAIL_SIGNATURE"] ?? "",
"secret" => getenv("QWICMAIL_WEBHOOK_SECRET"),
"tolerance_seconds" => 300,
]);
} catch (WebhookVerificationError $e) {
http_response_code(400);
exit;
}
error_log("{$evt->type} {$evt->messageId}");
http_response_code(200);@PostMapping(path = "/hooks/qwicmail",
consumes = MediaType.ALL_VALUE)
public ResponseEntity<Void> hook(@RequestBody byte[] raw,
@RequestHeader("X-Qwicmail-Signature") String sig) {
try {
Event evt = qm.webhooks().verify(VerifyParams.builder()
.body(raw)
.signature(sig)
.secret(System.getenv("QWICMAIL_WEBHOOK_SECRET"))
.toleranceSeconds(300)
.build());
log.info("{} {}", evt.type(), evt.messageId());
} catch (WebhookVerificationException e) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok().build();
}Detail shapes by event type
hard_bounced/soft_bounced—{ "source": "async_dsn", "status": "5.1.1", "diagnostic": "..." }complained—{ "source": "arf", "feedback_type": "abuse" }opened—{ "source": "pixel", "user_agent": "...", "ip": "..." }clicked—{ "source": "redirect", "tracked_link_id": "...", "url": "..." }unsubscribed—{ "source": "list-unsubscribe", "method": "one-click" }failed—{ "reason": "max_attempts", "smtp_message": "..." }
Retries
Non-2xx responses (or transport errors) are retried with exponential
backoff: 30s, 2m, 10m, 30m, 1h, 2h, 4h, 8h — eight attempts spanning
about 16 hours. After the final attempt the delivery is marked
failed and not retried. Receivers should aim for < 5s
response time; we time individual POSTs out at 10s.
If your handler does anything more involved than persisting the event, do that work after returning 200. The 10-second budget is for your endpoint to confirm receipt; the actual processing belongs in a background queue.
Testing locally
During development, point a webhook at https://<your-tunnel>/hooks/qwicmail via ngrok / Cloudflare Tunnel / similar, then send a test event from the portal's webhook detail page. The portal also stores the last 100 deliveries per endpoint (request, response, retries) for debugging.