Webhooks are how Qflow tells you what happened to an email after you triggered the send. Configure one URL per account; all comms events for your account arrive there.Documentation Index
Fetch the complete documentation index at: https://docs.qflowhub.io/llms.txt
Use this file to discover all available pages before exploring further.
Register a webhook
Other webhook endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /api/comms/webhook | Read configured URL (no secret) |
POST | /api/comms/webhook/rotate | Generate new secret, old stops working |
POST | /api/comms/webhook/test | Fire a synthetic comms.test event through the full signing pipeline — use during integration to verify your verifier works |
POST | /api/comms/webhook/delete | Clear webhook configuration |
Verifying signatures (HMAC)
Every webhook is HMAC-SHA256 signed with your secret. You must verify the signature on receipt to prevent forgery — your webhook URL is on the public internet, and without verification you can’t tell our events from anyone else’s POST.Headers
What HMAC is (mental model)
HMAC mixes a shared secret into a hash of the request body, producing a fingerprint only someone who knows the secret could have produced. Think wax seal on a letter — anyone can read the letter, but only someone with your seal could have made the imprint. The timestamp is included in the signed payload so an attacker who captures a valid request can’t replay it tomorrow. Your receiver rejects anything older than 5 minutes.How we sign (sender side, for context)
v1= prefix lets us version the algorithm. Accept any v1= for now; if we ship v2= later, accept the highest version your code knows.
How to verify (receiver side)
Read the raw body
Don’t reparse and re-stringify the JSON — the signature is over the exact bytes we sent. Most frameworks let you read raw bytes before JSON parsing.
Recompute the HMAC
Build
signed = t + "." + body exactly as we did, then HMAC-SHA256(yourSecret, signed), hex-encode.Constant-time compare
Compare your computed hex to the
v1= value using a constant-time function (most crypto libraries provide one). Regular == is vulnerable to timing attacks.Verification examples
Common mistakes
- Re-stringifying the JSON before HMAC. The signature is over our exact bytes, including whitespace. Read the raw body.
- Using
==to compare signatures. Use a constant-time compare. Timing attacks are real. - Skipping the timestamp check. Without the replay window, the signature alone doesn’t protect against captured requests.
- Logging the secret. Mask it like an API key.
- Not deduping
X-Qflow-Webhook-Id. We reuse the same id across retries — dedupe on it so you don’t process the same event twice. - Treating delivery as guaranteed. We retry on 5xx, but if your server is down for hours you’ll miss events. For critical state, reconcile by polling our API too.
Test your verifier
POST /api/comms/webhook/test fires a synthetic comms.test event through the full signing pipeline. Use it during integration to confirm your verification works before any real send.
Event types
| Type | When it fires |
|---|---|
comms.sent | Send job handed the email to SendGrid successfully |
comms.failed | Send job threw before SendGrid accepted (transient or permanent) |
comms.skipped | Retry hit the idempotency short-circuit (already sent) |
comms.delivered | SendGrid confirmed delivery to recipient mail server |
comms.opened | Recipient opened the email (one event per open — multiple possible) |
comms.bounced | Recipient mail server rejected the message |
comms.dropped | SendGrid dropped (suppression list, invalid address, etc.) |
comms.spam_reported | Recipient marked as spam |
comms.unsubscribed | Recipient clicked unsubscribe |
comms.test | Generated by POST /api/comms/webhook/test — safe to ignore |
X-Qflow-Event (or data.type in the body) to handle only what you care about.
Webhook payload shape
error is set only when status == "failed" or status == "dropped".
Operational notes
- Webhook delivery latency: typically sub-second once your account is active. After a quiet period, the first webhook can take up to ~1 minute due to queue polling backoff.
- Replay window: 300 seconds. If your receiver’s clock drifts more than ~5 min from ours, valid webhooks may be rejected as stale. Keep clocks NTP-synced.
- Webhook retries: if your endpoint returns 5xx, we retry with exponential backoff up to ~10 attempts. The same
X-Qflow-Webhook-Idis reused across retries — dedupe on it. After max retries, the message goes to a dead-letter queue. - No subscription/event-type filtering: today, all comms events go to your single webhook URL. Filter on
X-Qflow-Eventon your receiver.
