Skip to main content

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.

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.

Register a webhook

curl -X POST 'https://api.qflowhub.io/comms/v1/api/comms/webhook' \
  -H 'Authorization: Bearer <token>' \
  -H 'Ocp-Apim-Subscription-Key: <subscription_key>' \
  -H 'Content-Type: application/json' \
  -d '{"url": "https://your-app.com/webhooks/qflow"}'
Response (shown once — store the secret immediately):
{
  "url": "https://your-app.com/webhooks/qflow",
  "secret": "0d5124eefa6ed9ae1fece694f70c3c04e48ccb73c67f313548629220cfb61252"
}
The plaintext secret is shown exactly once at creation. We store it AES-encrypted and have no way to recover the plaintext. If you lose it, call POST /api/comms/webhook/rotate to get a new one (and update your verifier — the old one stops working immediately).

Other webhook endpoints

MethodPathPurpose
GET/api/comms/webhookRead configured URL (no secret)
POST/api/comms/webhook/rotateGenerate new secret, old stops working
POST/api/comms/webhook/testFire a synthetic comms.test event through the full signing pipeline — use during integration to verify your verifier works
POST/api/comms/webhook/deleteClear 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

X-Qflow-Signature:  t=<unix-timestamp>,v1=<64-char hex>
X-Qflow-Webhook-Id: <uuid>
X-Qflow-Event:      comms.sent | comms.delivered | comms.bounced | ...
Content-Type:       application/json

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)

unixTime  = current Unix time in seconds
body      = raw JSON we're about to POST
signed    = unixTime + "." + body          ← string concat, period as delimiter
digest    = HMAC-SHA256(secret, signed)    ← raw 32 bytes
hex       = digest as lowercase hex (64 chars)
header    = "t=" + unixTime + ",v1=" + hex
The 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)

1

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.
2

Parse X-Qflow-Signature

Split on ,, find t= and v1=.
3

Replay window check

Compare t to your current Unix time. Reject if difference > 300 seconds.
4

Recompute the HMAC

Build signed = t + "." + body exactly as we did, then HMAC-SHA256(yourSecret, signed), hex-encode.
5

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.
6

Process or reject

If it passes, process the event. If not, return 401 and ignore.

Verification examples

const crypto = require('crypto');

app.post('/qflow/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sigHeader = req.header('X-Qflow-Signature') || '';
  const parts = Object.fromEntries(sigHeader.split(',').map(p => p.split('=')));
  const t = parseInt(parts.t, 10);
  const v1 = parts.v1 || '';

  if (!t || Math.abs(Math.floor(Date.now()/1000) - t) > 300) return res.sendStatus(401);

  const body = req.body.toString('utf8');
  const expected = crypto
    .createHmac('sha256', process.env.QFLOW_WEBHOOK_SECRET)
    .update(`${t}.${body}`)
    .digest('hex');

  const ok = expected.length === v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(body);
  // ... handle event ...
  res.sendStatus(200);
});

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

TypeWhen it fires
comms.sentSend job handed the email to SendGrid successfully
comms.failedSend job threw before SendGrid accepted (transient or permanent)
comms.skippedRetry hit the idempotency short-circuit (already sent)
comms.deliveredSendGrid confirmed delivery to recipient mail server
comms.openedRecipient opened the email (one event per open — multiple possible)
comms.bouncedRecipient mail server rejected the message
comms.droppedSendGrid dropped (suppression list, invalid address, etc.)
comms.spam_reportedRecipient marked as spam
comms.unsubscribedRecipient clicked unsubscribe
comms.testGenerated by POST /api/comms/webhook/test — safe to ignore
Filter on X-Qflow-Event (or data.type in the body) to handle only what you care about.

Webhook payload shape

{
  "id": "<webhook uuid>",
  "type": "comms.delivered",
  "createdAt": "2026-04-29T10:23:00.000Z",
  "data": {
    "attendeeId": "<the guest GUID you supplied>",
    "templateId": "<the template GUID you sent>",
    "attendeeInvitationId": "<server-side tracking GUID>",
    "status": "delivered",
    "error": null,
    "correlationId": "<echoed from your POST, if you provided one>"
  }
}
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-Id is 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-Event on your receiver.