Welcome
Integration guide for the Qflow API — events, attendees, templates, and transactional email.
This guide covers everything you need to integrate with Qflow. By the end you'll have:
- An OAuth bearer token issued for your account
- A reusable email template with merge tokens (e.g.
{{firstname}},{{barcode}}) - A receiver URL that verifies HMAC-signed webhooks for every send / delivery / open
- Your first attendee created and their welcome email queued in a single API call
Read it cover-to-cover and you'll have your first email out the door in under 30 minutes.
Your endpoint
https://api-uksouth-01.qflowhub.io
That's it — every endpoint we'll cover is on this single host. There's no separate prefix for "events" vs. "email" — they're all on the same base URL. Examples:
POST /api/guest— create an attendee (with optional atomic email send)POST /api/comms/template— create or update an email templatePOST /api/comms/webhook— register your delivery-event receiverPOST /api/comms/domain— (optional) authenticate a custom sending domain
Two headers on every request
Authorization: Bearer <your-access-token>
X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359
The bearer token is OAuth, refreshable, and the standard auth pattern. The client key is an extra hardening layer specific to your integration — the value above is yours, baked into every example in this guide. Without both, requests fail with 401.
Detailed flow in Authentication.
Your account-specific values
Everything you need is either listed below, embedded in this guide, or returned by an API call:
| Value | Where to get it |
|---|---|
OAuth client_id + client_secret |
Supplied by Qflow support out-of-band — keep secret |
| Qflow username + password | Supplied by Qflow support — keep secret |
X-Qflow-Client-Key |
2c2dd698e83b1b2d8340ab76b2c41359 (already pre-filled in every curl example) |
| Webhook signing secret | Returned once when you call POST /api/comms/webhook — store immediately, can't be recovered |
| Access token | Issued by https://identity.qflowhub.io/core/connect/token — see Authentication |
Suggested reading order
- Authentication —
Get your first bearer token. See Authentication.
- Quick start —
End-to-end walkthrough — token to first email in five steps. See Quick start.
- Templates —
Author the HTML email body with merge tokens. See Email templates.
- Webhooks —
Set up a receiver, verify HMAC signatures, observe deliveries. See Webhooks.
- Sending —
Two send patterns — atomic create+send via
/api/guest, or two-step via/api/comms/send. See Sending. - Custom sending domain (optional) —
Use your own
noreply@yourbrand.cominstead of the default sender. See Custom sending domain.
What we've already verified
The flows in this guide have been tested end-to-end against the live API:
- ✓ Bearer token + client-key gate firing correctly
- ✓ Template create/upsert with merge tokens and partial-update semantics
- ✓ Atomic create-and-send via
POST /api/guestwithcommsTemplateId - ✓ Two-step send via
POST /api/comms/sendto existing attendees - ✓ Webhook signing (HMAC-SHA256) and the full delivery-event chain (
comms.sent→comms.delivered→comms.opened) - ✓ Custom domain authentication via SendGrid Domain Authentication
- ✓ Per-IP rate limiting (60 requests/minute) — well above any realistic flow
If anything in this guide doesn't match what you observe in your integration, email support@qflow.io — we'll look at it.
Need an HTTPS receiver to get started fast?
webhook.site and pipedream.com both give you a free, instant HTTPS URL that captures and displays POSTs in real time. Either one is perfect for getting your first webhook arriving while you build out a real receiver. We'll show how to use them in Webhooks.
Authentication
OAuth bearer tokens + client-key header. Two values, one identity provider, all server-side.
Every API request needs two headers:
Authorization: Bearer <oauth_access_token>
X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359
This page covers how you get each one.
What Qflow support gives you
Three values, supplied out-of-band when your integration is provisioned:
| Value | What it's for |
|---|---|
OAuth client_id + client_secret |
App credentials — used to exchange a username/password for an access token |
| A username + password (or an OAuth user that you control) | The Qflow user account your sends and templates are scoped to |
X-Qflow-Client-Key value |
2c2dd698e83b1b2d8340ab76b2c41359 — pre-filled in every example in this guide |
Keep all three secret — client_secret, password, and the client key all grant access to your account. None of them appear in URLs or response bodies, only in request headers / bodies. The client key value above is delivered to you only inside this access-controlled guide; treat it like the other credentials.
Getting a bearer token
OAuth 2.0 password grant against the Qflow identity provider:
curl -X POST 'https://identity.qflowhub.io/core/connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=password' \
-d 'client_id=<your-client-id>' \
-d 'client_secret=<your-client-secret>' \
-d 'username=<your-username>' \
-d 'password=<your-password>' \
-d 'scope=qflowapi+openid+offline_access'
Response:
{
"access_token": "ff1a4e41ce9160f95c8e090e2e75276c",
"expires_in": 3600,
"token_type": "Bearer",
"refresh_token": "...",
"scope": "qflowapi openid profile email offline_access"
}
The access_token is what goes in your Authorization header. Tokens are valid for one hour by default.
Refreshing the token
Use the refresh_token from the response above to get a new access token without prompting for the password again:
curl -X POST 'https://identity.qflowhub.io/core/connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=refresh_token' \
-d 'client_id=<your-client-id>' \
-d 'client_secret=<your-client-secret>' \
-d 'refresh_token=<your-refresh-token>'
A typical pattern is to refresh ~5 minutes before expiry, or fall back to a fresh password grant if the refresh fails.
Sending the headers
Every request to https://api-uksouth-01.qflowhub.io/api/... carries both:
curl -X GET 'https://api-uksouth-01.qflowhub.io/api/event/<eventId>' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359'
Common 401 responses
| Body | Meaning | Fix |
|---|---|---|
{"message":"Authorization has been denied for this request."} |
Bearer token missing / invalid / expired | Refresh or re-acquire the token |
{"error":"client_key_invalid","message":"Missing or invalid X-Qflow-Client-Key header."} |
Client-key header missing or wrong | Check the value; it's a constant supplied by Qflow support |
A 2xx from any endpoint confirms both checks passed.
Token storage tips
- Store
client_id,client_secret, and the client key as environment variables or in a secret manager — never in source control - Cache the access token until ~5 minutes before
expires_inrather than fetching one per request - If you rotate the client key (security incident, etc.) Qflow support hands you the new value out-of-band — there's no API to read or change it from your side
Quick start
From zero to first email out the door in five steps. Real curl examples — copy, replace placeholders, run.
This walks through a complete integration end-to-end. By the last step you'll have:
- A working bearer token
- A receiver URL receiving HMAC-signed webhook events
- A custom email template stored on your account
- An attendee created and their welcome email queued in a single API call
- Confirmation that all three webhook events (
sent,delivered,opened) arrived at your receiver
Plan to spend ~20 minutes if you're new. Less if you've done OAuth password flow before.
Step 0 — Set up a quick webhook receiver
If you don't have a receiver URL yet (you'll wire up the real one later), grab a free one from either:
- webhook.site — open the page, copy the URL it shows ("Your unique URL is..."). Every POST to that URL appears live in the browser.
- pipedream.com — create a free account, set up a "HTTP Trigger" workflow, copy the URL.
Either works for the rest of this walkthrough. You'll replace it later with the URL of your actual receiver service.
Step 1 — Get a bearer token
curl -X POST 'https://identity.qflowhub.io/core/connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=password' \
-d 'client_id=<your-client-id>' \
-d 'client_secret=<your-client-secret>' \
-d 'username=<your-username>' \
-d 'password=<your-password>' \
-d 'scope=qflowapi+openid+offline_access'
Save the access_token from the response — you'll use it on every subsequent call.
Step 2 — Create an event
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/event' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359' \
-H 'Content-Type: application/json' \
-d '{
"title": "My first event",
"startTime": "2026-06-01T10:00:00",
"endTime": "2026-06-01T22:00:00",
"timeZoneId": "Europe/London"
}'
Save the returned id — that's your eventId for the rest of the walkthrough.
Step 3 — Register your webhook receiver
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/comms/webhook' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359' \
-H 'Content-Type: application/json' \
-d '{"url": "<your-receiver-url-from-step-0>"}'
Response:
{
"url": "<your-receiver-url-from-step-0>",
"secret": "45638ff7bb801d9487c0d07389f6893df95007e71af145be26e6efcc7e9580c0"
}
The secret is shown ONCE. Save it now — you'll use it to verify HMAC signatures on incoming webhooks. We can't recover it; if you lose it, call POST /api/comms/webhook/rotate to get a new one (and update your verifier).
Quick test that the receiver is reachable:
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/comms/webhook/test' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359' \
-d ''
A comms.test event should appear at your receiver within seconds. If it doesn't, double-check the URL is HTTPS, accepts POST, and isn't behind any IP allow-list. See Webhooks → Verifying signatures when you're ready to wire up signature verification.
Step 4 — Create an email template
The email body must be HTML and should contain merge tokens so each recipient gets a personalised email. Tokens look like {{firstname}}, {{barcode}}, {{event}} — see Email templates → Merge tokens for the full list.
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/comms/template' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359' \
-H 'Content-Type: application/json' \
-d '{
"eventId": "<eventId from step 2>",
"name": "Welcome ticket",
"subject": "Your ticket for {{event}}, {{firstname}}",
"html": "<h1>Hi {{firstname}}!</h1><p>Show this at the door:</p><p><img src=\"{{barcode_qr}}\" alt=\"ticket\" /></p>"
}'
Save the returned template id.
Step 5 — Atomic create-and-send
POST /api/guest accepts an optional commsTemplateId. When set, Qflow creates the attendee and queues their email send in one round-trip:
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/guest' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359' \
-H 'Content-Type: application/json' \
-d '{
"id": "<a fresh GUID you generate>",
"firstName": "Joe",
"lastName": "Bloggs",
"email": "joe@yourcompany.com",
"eventId": "<eventId from step 2>",
"tags": "VIP",
"commsTemplateId": "<templateId from step 4>",
"correlationId": "first-test"
}'
That's it. You'll see, in this order, on your receiver:
comms.sentwithin seconds — Qflow accepted the email and SendGrid acknowledged itcomms.deliveredwithin ~30-90 seconds — recipient mail server confirmed receiptcomms.openedwhen the recipient opens the email (or their email client's image proxy fetches the open tracker)
Each event carries the same correlationId (first-test here), so you can trace one logical send through its whole lifecycle.
If the email lands in spam, that's a sender-domain issue — your default sender works for testing but you'll want a custom sending domain for production deliverability.
What's next
You've completed a working integration. From here:
- Production-grade webhook verification — see Webhooks for the HMAC-SHA256 verification code in the language of your choice
- Real branding — set up a custom sending domain so emails come from
noreply@yourbrand.cominstead of the defaultsupport@qflow.email - Bulk sending patterns — see Sending → Two-step send for resending to attendees that already exist
- Error catalogue + rate limits — see Reference
Email templates
Stored HTML email content with merge tokens. Required HTML body, optional sender overrides, partial-update friendly.
A template is the email content you send. It's stored on your account and reused across many sends — you author it once, then reference its id when creating attendees.
HTML email is the default and recommended approach. It delivers reliably across every modern mail provider, supports merge tokens for personalisation, and lands in the inbox rather than spam. PDF attachment is also supported (covered later) but adds deliverability risk and ~1-2s of latency per send — only reach for it when you genuinely need a printable artefact alongside the email.
What a template needs
| Field | Required? | Notes |
|---|---|---|
eventId |
Yes | The event the template is scoped to — one template per event, you can have many templates per event |
name |
Yes | Internal label — appears in your admin lists, never shown to recipients |
subject |
Recommended | Email subject line. Supports merge tokens (e.g. "Hi {{firstname}}") |
html |
Yes | The email body. Must be HTML. Plain text isn't supported — see HTML body below |
fromAddress |
Optional | Override the From: header. Must match your verified custom domain — see fromAddress validation |
fromName |
Optional | Override the From-name (e.g. "Acme Events") |
replyTo |
Optional | Override Reply-To header |
attachPdf |
Optional, default false |
Advanced. Attach a PDF rendered from html — see PDF attachment |
emailBody |
Optional | Advanced. Plain courier-style email body when attachPdf: true — see Courier mode |
HTML body
The html field is required and must be an HTML document fragment. Plain text isn't supported — emails sent without HTML are treated as malformed by most modern mail clients and routinely land in spam.
A minimal viable body:
<h1>Hi {{firstname}}!</h1>
<p>Your ticket for {{event}} on {{date}} is below.</p>
<p><img src="{{barcode_qr}}" alt="ticket" /></p>
<p>See you there.</p>
Templates with proper structure, branding, and an explicit unsubscribe link will deliver better. SendGrid is doing the heavy lifting on signing/encryption, but recipient mail servers still apply heuristics — well-formed HTML helps.
Merge tokens
Tokens are replaced per-recipient at send time. The full list:
| Token | Replaced with |
|---|---|
{{firstname}} |
Attendee's first name |
{{lastname}} |
Attendee's last name |
{{fullname}} |
First + last |
{{othernames}} |
Middle names |
{{email}} |
Attendee's email |
{{barcode}} |
Attendee's barcode (auto-generated if missing) — raw string e.g. 4GA6965Q1G5EZ0FB |
{{barcode_qr}} |
Fully-qualified QR-code image URL — drop into <img src="{{barcode_qr}}" alt="ticket" /> |
{{plusones}} |
Number of plus-ones (or empty if 0) |
{{guestnotes}} |
Attendee's additionalInfo field |
{{tags}} / {{tickets}} / {{sessions}} |
Tag groups (one per line if multiple) |
{{event}} |
Event title |
{{date}} |
Event start date in event timezone (dd MMMM yyyy) |
{{starttime}}, {{endtime}} |
Event times in event timezone (HH:mm) |
{{companyname}} |
Your company name (set on your account profile) |
{{rsvplink_yes}}, {{rsvplink_no}} |
RSVP confirmation links |
{{V_1}}, {{V_2}}, ... |
Custom field values from the attendee's profile |
Tokens that don't match (typo, no data) are left literal in the output — {{firstnam}} (typo) ships as {{firstnam}} in the email rather than blanking out.
Create / upsert a template
The minimal HTML template — what you'll start with:
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/comms/template' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359' \
-H 'Content-Type: application/json' \
-d '{
"id": "<your guid (optional — for idempotent upserts)>",
"eventId": "<event guid>",
"name": "Welcome ticket",
"subject": "Your ticket for {{event}}, {{firstname}}",
"html": "<h1>Hi {{firstname}}!</h1><p>Your ticket: {{barcode}}</p>"
}'
That's everything you need for a working email. Add sender overrides (fromAddress, fromName, replyTo) when you've set up a custom sending domain. Add attachPdf only if you need a PDF artefact.
Response (201/200):
{
"id": "7bc31757-5a82-4d7a-8767-bb508f6d3481",
"eventId": "...",
"name": "Welcome ticket",
"subject": "Your ticket for {{event}}, {{firstname}}",
"html": "<h1>...",
"fromAddress": "tickets@yourbrand.com",
"fromName": "Your Brand Tickets",
"replyTo": "support@yourbrand.com",
"attachPdf": false,
"emailBody": null,
"created": "2026-05-08T10:00:00Z"
}
Capture the id — that's the value you'll pass as commsTemplateId when sending. If you supply your own id on create, the call is idempotent: posting the same id twice with different content updates the existing template.
Partial updates
Upserts (a POST that re-uses an existing id) only modify the fields you include. Omitted optional fields keep their existing values — so you can change just the subject without re-sending sender details.
| To... | Send... |
|---|---|
| Update a field | the new value |
| Leave a field as it was | omit it (or send null) |
| Clear a previously-set field | empty string "" |
The clear-with-"" rule applies to fromAddress, fromName, replyTo, emailBody. attachPdf is a bool? — send false to turn it off, omit to leave alone.
name, subject, and html are always overwritten with whatever you send — they're considered the core of the template, not optional overrides.
fromAddress validation
When you set fromAddress, the domain part must match (or be a subdomain of) the custom domain you've authenticated for your account via Custom sending domain. This prevents misconfigured templates from silently failing at SendGrid.
fromAddress value |
Result |
|---|---|
Omitted or null |
No check — uses default sender |
Empty string "" |
No check — clears any previous override |
Domain matches your verified domain (e.g. events@yourbrand.com if you've verified yourbrand.com) |
✓ saved |
Subdomain of your verified domain (e.g. noreply@mail.yourbrand.com if you've verified yourbrand.com) |
✓ saved |
| Domain you haven't authenticated | 422 fromaddress_domain_not_verified |
| Account has no verified domain at all | 422 fromaddress_domain_not_verified |
If validation can't run (transient SendGrid lookup issue), you'll see 502 fromaddress_validation_unavailable — retry shortly.
PDF attachment
Advanced. Plain HTML emails are the recommended default for deliverability. PDF attachment is fine for ticket-with-printable-artefact use cases, but recipient mail providers treat attachment-bearing emails more aggressively (heavier spam filtering, slower throughput). Add it only when you genuinely need the PDF — and pair it with a custom sending domain for best results.
Setting attachPdf: true makes every send from that template attach a PDF. The PDF is rendered from the same html your email body uses.
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/comms/template' \
-H 'Authorization: Bearer <access_token>' -H 'X-Qflow-Client-Key: <key>' \
-H 'Content-Type: application/json' \
-d '{
"id": "<guid>",
"eventId": "<event guid>",
"name": "Ticket PDF",
"subject": "Your ticket — {{firstname}}",
"html": "<h1>Hi {{firstname}}!</h1><p>Ticket QR: {{barcode_qr}}</p>",
"attachPdf": true
}'
By default the email body matches the PDF — your rich html is sent as both the email body and the attachment. In many ticket designs that's what you want.
PDF rendering adds ~1-2 seconds of latency per send. Pace bulk flushes accordingly.
Courier mode (emailBody)
If you'd rather the email itself be a short courier-style note ("Hi Joe, your ticket is attached") and the rich design lives only in the PDF, set emailBody:
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/comms/template' \
-H 'Authorization: Bearer <access_token>' -H 'X-Qflow-Client-Key: <key>' \
-H 'Content-Type: application/json' \
-d '{
"id": "<guid>",
"eventId": "<event guid>",
"name": "Ticket PDF (courier body)",
"subject": "Your ticket — {{firstname}}",
"html": "<h1>Hi {{firstname}}!</h1><p>Ticket QR: {{barcode_qr}}</p>",
"emailBody": "<p>Hi {{firstname}},</p><p>Your ticket for {{event}} is attached.</p>",
"attachPdf": true
}'
When attachPdf: true and emailBody is set, the PDF carries html and the email carries emailBody. When emailBody is null and attachPdf: true, the email body falls back to "Please find your invitation attached."
emailBody supports the same merge tokens as html. Ignored entirely when attachPdf: false.
Other endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/api/comms/template/{id} |
Fetch one template |
GET |
/api/comms/templates?eventId=<guid> |
List templates for an event |
POST |
/api/comms/template/delete |
Delete a template (body: {"id": "<guid>"}) |
URL safety scanning
Template HTML is automatically scanned for malicious URLs at create time. Templates flagged unsafe are rejected with 422:
{
"error": "spam_check_failed",
"message": "Template HTML contains URLs flagged as unsafe...",
"flaggedUrls": [{"url": "https://...", "threatType": "MALWARE"}]
}
Remove the offending URLs and retry.
Webhooks
Receive HMAC-signed delivery events for every send. Ten lines of verification code, one secret you store once.
Qflow POSTs to your URL every time something noteworthy happens to an email — sent, delivered, opened, bounced, dropped, spam-reported, unsubscribed. Each event carries:
- The original
correlationIdyou supplied (so you can match an event back to your own record) - An HMAC-SHA256 signature in
X-Qflow-Signature(so you can verify it's really us) - A timestamp, dedupe id, and event-type header
This page covers: registering your URL, getting a free test receiver, the wire format, and HMAC verification in five languages.
Quick test with a free receiver
If you don't have a real receiver yet:
- webhook.site — open it, copy "Your unique URL is..." from the page. Done.
- pipedream.com — sign up free, "New Workflow" → "HTTP / Webhook" trigger → copy the URL.
Either of these gives you an HTTPS URL that displays incoming POSTs in real time. Perfect for getting webhooks flowing immediately while you build the real receiver later.
Register your URL
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/comms/webhook' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359' \
-H 'Content-Type: application/json' \
-d '{"url": "https://your-receiver.example.com/qflow"}'
Response:
{
"url": "https://your-receiver.example.com/qflow",
"secret": "45638ff7bb801d9487c0d07389f6893df95007e71af145be26e6efcc7e9580c0"
}
Save the secret immediately — it's shown exactly once. We store it AES-encrypted; we cannot 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
| Method | Path | Purpose |
|---|---|---|
GET |
/api/comms/webhook |
Read configured URL (no secret) |
POST |
/api/comms/webhook/rotate |
Generate a new secret (old one stops working) |
POST |
/api/comms/webhook/test |
Fire a synthetic comms.test event through the full signing pipeline |
POST |
/api/comms/webhook/delete |
Clear webhook config — no events fire after this |
Empty-body POSTs (/rotate, /test, /delete) need an explicit Content-Length: 0. Most HTTP clients handle this for you, but raw curl returns 411 Length Required unless you pass -d '':
# Wrong — IIS rejects with 411
curl -X POST -H '...' https://api-uksouth-01.qflowhub.io/api/comms/webhook/rotate
# Right — explicit empty body
curl -X POST -H '...' -d '' https://api-uksouth-01.qflowhub.io/api/comms/webhook/rotate
Test your wiring
After registering, fire a synthetic event to confirm the round-trip works:
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/comms/webhook/test' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359' \
-d ''
Within seconds you should see a comms.test event arrive at your receiver — same headers, same signing, same shape as production events. Use it during integration to verify your verification code works before any real send.
Wire format
Headers
Content-Type: application/json
User-Agent: Qflow-Webhook/1.0
X-Qflow-Webhook-Id: <uuid> ← unique per event, use as a dedupe key
X-Qflow-Event: <event-type> ← e.g. "comms.delivered"
X-Qflow-Timestamp: <unix-seconds> ← when the event was created
X-Qflow-Signature: <hex-hmac> ← HMAC-SHA256 of the request body, hex-encoded
Body shape
{
"attendeeId": "f9804be1-4d31-4dc2-917c-ff3e9b6a9a41",
"templateId": "00890adf-1a80-4b1f-87ce-620feb9bec12",
"attendeeInvitationId": "8d630377-970a-4687-aa66-2a18fb964c99",
"status": "delivered",
"error": null,
"correlationId": "first-test"
}
Verifying signatures (HMAC)
You must verify the signature on receipt. Without it you can't tell our events from any random POST hitting your URL.
The signature is HMAC-SHA256 of the raw body keyed with your secret, hex-encoded. Compute the same on your side and compare in constant time.
Node.js (Express)
const crypto = require('crypto');
app.post('/qflow-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.header('X-Qflow-Signature');
const expected = crypto.createHmac('sha256', process.env.QFLOW_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!signature || !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString());
console.log(req.header('X-Qflow-Event'), event);
res.sendStatus(200);
});
Important: use express.raw() not express.json(). JSON parsing changes the byte sequence (whitespace, key order) — your HMAC will never match if you sign the parsed object.
Python (Flask)
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['QFLOW_WEBHOOK_SECRET']
@app.post('/qflow-webhook')
def webhook():
signature = request.headers.get('X-Qflow-Signature', '')
expected = hmac.new(SECRET.encode(), request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json()
print(request.headers['X-Qflow-Event'], event)
return '', 200
.NET (ASP.NET Core)
[HttpPost("/qflow-webhook")]
public async Task<IActionResult> Receive()
{
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var signature = Request.Headers["X-Qflow-Signature"].ToString();
var secret = Environment.GetEnvironmentVariable("QFLOW_WEBHOOK_SECRET");
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(body))).ToLower();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signature),
Encoding.UTF8.GetBytes(expected)))
return Unauthorized();
var ev = JsonSerializer.Deserialize<JsonElement>(body);
Console.WriteLine($"{Request.Headers["X-Qflow-Event"]} {ev}");
return Ok();
}
Go
func qflowWebhook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mac := hmac.New(sha256.New, []byte(os.Getenv("QFLOW_WEBHOOK_SECRET")))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
sig := r.Header.Get("X-Qflow-Signature")
if !hmac.Equal([]byte(sig), []byte(expected)) {
http.Error(w, "invalid signature", 401)
return
}
log.Printf("%s %s", r.Header.Get("X-Qflow-Event"), body)
w.WriteHeader(200)
}
Ruby (Sinatra)
post '/qflow-webhook' do
body = request.body.read
expected = OpenSSL::HMAC.hexdigest('sha256', ENV['QFLOW_WEBHOOK_SECRET'], body)
halt 401 unless Rack::Utils.secure_compare(request.env['HTTP_X_QFLOW_SIGNATURE'].to_s, expected)
event = JSON.parse(body)
puts "#{request.env['HTTP_X_QFLOW_EVENT']} #{event}"
200
end
Common verification mistakes
- Parsing the body before signing — JSON
parse → stringifyproduces a byte sequence different from what we signed. Always use the raw body bytes. - Ordinary string
==instead of constant-time compare — leaks timing info. Always usetimingSafeEqual/compare_digest/FixedTimeEquals/hmac.Equal/secure_compare. - Hex-decoding the signature first — both
X-Qflow-Signatureand your computed value are hex strings, compare them as strings.
Event types
| Event | When |
|---|---|
comms.test |
Generated by POST /api/comms/webhook/test — safe to ignore in production code |
comms.sent |
Email was accepted by SendGrid for delivery |
comms.delivered |
Recipient mail server confirmed receipt |
comms.opened |
Recipient (or their email client) loaded the email |
comms.bounced |
Recipient mail server rejected — see error for reason |
comms.dropped |
SendGrid dropped before delivery (e.g. recipient already on suppression list) |
comms.spam_reported |
Recipient marked as spam — they're auto-suppressed from future sends |
comms.unsubscribed |
Recipient hit the unsubscribe link |
comms.failed |
Internal failure during the send pipeline (rare) |
A typical successful send produces three events in order: comms.sent → comms.delivered → (comms.opened if/when they open it). A bounce produces comms.sent → comms.bounced.
Operational notes
- Latency: typically sub-second between SendGrid recording the event and your URL receiving it. After a quiet period (no comms activity for several minutes), the first webhook can take up to ~1 minute due to queue cold-start.
- 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.
- 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. - Filtering: today, all comms events go to your single configured URL. Filter on
X-Qflow-Eventon your receiver if you only care about a subset.
Custom sending domain
Send from your own domain instead of the default. SendGrid Domain Authentication, three DNS records, one verify call.
By default, emails go from support@qflow.email (the default sender). For production deliverability and brand consistency, you'll want emails to come from noreply@yourbrand.com or similar — a domain you control.
This page sets that up. You only need to do it once per account, then any template's fromAddress under that domain works automatically.
Lifecycle
- Create the domain auth record —
POST /api/comms/domainwith the apex domain (e.g.yourbrand.com). Qflow registers it with SendGrid and returns three DNS records you need to publish. - Publish the DNS records —
Add the three returned CNAME records at your domain registrar / DNS provider. Allow DNS propagation (usually a few minutes, occasionally up to ~24 hours).
- Verify —
POST /api/comms/domain/verify. SendGrid validates DNS; on success, sending from your domain is automatically activated.
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/api/comms/domain |
Create — body: {"domain": "yourbrand.com"} |
GET |
/api/comms/domain |
Read current state (id, domain, verified, enabled, DNS records) |
POST |
/api/comms/domain/verify |
Validate DNS at SendGrid; auto-activates on success |
POST |
/api/comms/domain/activate |
Re-enable after a manual deactivate (no DNS recheck) |
POST |
/api/comms/domain/deactivate |
Disable without losing setup (sends fall back to default sender) |
POST |
/api/comms/domain/delete |
Remove entirely (clears your domain id, deletes from SendGrid) |
Empty-body POSTs (/verify, /activate, /deactivate, /delete) need an explicit Content-Length: 0. Most HTTP clients handle this automatically, but raw curl returns 411 Length Required unless you pass -d ''.
Create
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/comms/domain' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359' \
-H 'Content-Type: application/json' \
-d '{"domain": "yourbrand.com"}'
Response:
{
"id": 44936656,
"domain": "yourbrand.com",
"verified": false,
"enabled": false,
"dnsRecords": [
{"type": "cname", "host": "em8291.yourbrand.com", "data": "u44936656.wl157.sendgrid.net", "valid": false},
{"type": "cname", "host": "s1._domainkey.yourbrand.com", "data": "s1.domainkey.u44936656.wl157.sendgrid.net", "valid": false},
{"type": "cname", "host": "s2._domainkey.yourbrand.com", "data": "s2.domainkey.u44936656.wl157.sendgrid.net", "valid": false}
]
}
The dnsRecords array is what you publish at your DNS provider. Three CNAMEs:
- One mail-routing CNAME (
em*.yourbrand.com) - Two DKIM signing CNAMEs (
s1._domainkey.yourbrand.com,s2._domainkey.yourbrand.com)
The id is for our records — you don't need to send it back; the API uses it internally per-account.
Verify
After the DNS records are live (use dig or your DNS provider's status page to confirm propagation):
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/comms/domain/verify' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359' \
-d ''
Successful response:
{
"verified": true,
"enabled": true,
"reasons": []
}
If verification fails:
{
"verified": false,
"enabled": false,
"reasons": [
{"name": "mail_cname", "valid": false, "reason": "Expected CNAME for \"em8291.yourbrand.com\" to match \"u44936656.wl157.sendgrid.net\"."},
...
]
}
Each entry tells you which CNAME isn't propagated yet. Wait for DNS to settle and call /verify again — there's no rate limit on verify attempts.
Activate / Deactivate
/activate and /deactivate toggle the enabled flag without touching SendGrid records. Use /deactivate when you suspect DNS issues are hurting deliverability — your sends fall back to the default sender immediately, but the SendGrid record + DNS setup stay in place.
Re-/verify (which auto-activates on success) or /activate (no DNS recheck) when you're ready to resume.
/activate returns 409 domain_not_verified if the domain has never passed verification. First activation must follow a successful /verify.
Delete
POST /api/comms/domain/delete clears the domain id from your account and deletes the record at SendGrid. After delete, GET /api/comms/domain returns 404. You can recreate the same domain freshly afterwards.
Using your verified domain
Once verified+enabled, you can use any address under that domain as fromAddress on a template:
# After verifying yourbrand.com:
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/comms/template' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359' \
-H 'Content-Type: application/json' \
-d '{
"eventId": "<event guid>",
"name": "Welcome — branded",
"subject": "Welcome, {{firstname}}",
"html": "<h1>Hi {{firstname}}</h1>...",
"fromAddress": "events@yourbrand.com",
"fromName": "Your Brand"
}'
Subdomains work too — noreply@mail.yourbrand.com is fine if you've verified yourbrand.com. See Email templates → fromAddress validation.
One domain per account
Currently you can only have one verified domain per account. If you want to switch to a different domain:
POST /api/comms/domain/deleteto remove the existing onePOST /api/comms/domainwith the new domain- Publish DNS, then
/verify
If you need multi-domain support, email support@qflow.io — it's on the roadmap, contact us if it's important to your integration.
Sending
Two patterns: atomic create-and-send via /api/guest, or two-step send to existing attendees via /api/comms/send.
There are two patterns for triggering a send. Pick whichever fits your existing flow:
| Pattern | Use when |
|---|---|
Atomic — POST /api/guest with commsTemplateId |
You're creating an attendee and want their welcome email queued in the same call. The most common pattern. |
Two-step — POST /api/comms/send |
The attendee already exists (bulk-imported, created via another endpoint, or "I lost my ticket" resend) and you want to fire a send to them. |
Both go through the identical send pipeline, signing, and webhook fan-out — just different entry points.
Atomic create-and-send
Add commsTemplateId to a regular POST /api/guest payload:
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/guest' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359' \
-H 'Content-Type: application/json' \
-d '{
"id": "<a fresh GUID you generate>",
"firstName": "Joe",
"lastName": "Bloggs",
"email": "joe@yourcompany.com",
"eventId": "<event guid>",
"tags": "VIP",
"commsTemplateId": "<template guid>",
"correlationId": "order-12345"
}'
Response is the standard Guest body — the send is queued asynchronously and reported via webhook.
correlationId
Optional opaque string echoed back in every webhook event for this send. Use it to thread your own identifier (an order id, ticket id, etc.) through the lifecycle. If you don't supply one, webhooks have a null correlationId — you'd have to match by attendeeId or attendeeInvitationId instead.
Idempotency
Re-posting the same id returns the existing attendee record without creating a duplicate or triggering a duplicate send. Safe to retry on network failures.
# First call — creates attendee, queues send
POST /api/guest { "id": "abc-123", ... } → 200 (created + queued)
# Network blip; client retries with same body
POST /api/guest { "id": "abc-123", ... } → 200 (existing record, NO duplicate send)
Two-step send
For attendees that already exist:
curl -X POST 'https://api-uksouth-01.qflowhub.io/api/comms/send' \
-H 'Authorization: Bearer <access_token>' \
-H 'X-Qflow-Client-Key: 2c2dd698e83b1b2d8340ab76b2c41359' \
-H 'Content-Type: application/json' \
-d '{
"attendeeId": "<attendee guid>",
"templateId": "<template guid>",
"correlationId": "resend-202605"
}'
Response:
{
"attendeeInvitationId": "8d630377-970a-4687-aa66-2a18fb964c99",
"status": "queued"
}
The send is queued; outcome arrives via webhook (comms.sent, then comms.delivered, etc.) — same as atomic.
Eligibility checks
/api/comms/send runs an extra check before queueing: if the recipient has previously had a permanent failure (bounce, spam-report, drop, unsubscribe), the call returns 422:
{
"error": "previous_send_permanently_failed",
"message": "Recipient cannot be emailed: spam_reported on 2026-04-12.",
"failureType": "spam_reported",
"lastFailureAt": "2026-04-12T14:32:00Z",
"lastFailureReason": "..."
}
Sending again won't help — the recipient is on SendGrid's suppression list for your account. If you have legitimate reason to retry (the user explicitly asked, error was transient, etc.), email support@qflow.io to discuss removing the suppression.
Atomic vs two-step — when to use which
Use atomic for the standard "user just signed up / bought a ticket" flow:
your-checkout → POST /api/guest with commsTemplateId → done
One call, idempotent, atomic. The most common pattern.
Use two-step for:
- Bulk imports — first import all attendees via
POST /api/guests/{eventId}(no per-attendee email), then send to them later in a controlled flow - Resends — "I lost my ticket" / "wrong email entered, please resend"
- Multiple templates per attendee — initial welcome, then a separate session-reminder later, then a follow-up survey
You can always two-step send to attendees created via the atomic flow too — atomic just means "the first send was kicked off automatically".
What "sending" actually does
Both patterns end up in the same async pipeline:
- Validate — template exists, attendee belongs to your account, recipient hasn't permanently failed
- Render — substitute merge tokens (
{{firstname}},{{barcode}}, etc.) with the attendee's data - Hand off to SendGrid — with the From-address resolved (custom domain if you have one, default otherwise)
- Fire
comms.sentto your webhook — the moment SendGrid acknowledges the message - Wait for SendGrid delivery events — fired to your webhook as
comms.delivered,comms.opened,comms.bounced, etc.
A typical successful send produces three webhook events in order: comms.sent (within ~1s), comms.delivered (within ~30-90s), comms.opened (when the recipient opens the email — minutes to days later, or never).
See Webhooks for the full event catalogue and verification details.
Rate limits
Sending goes through the same two-layer rate limit as the rest of the API: 60 requests/minute at the Cloudflare edge and 60 requests/minute in-app per gated user. Both apply.
For atomic create-and-send (one signup → one API call) you'll never come close. For bulk-import resends, pace at ≤1 per second and you stay well under both layers.
If you trip a limit you'll see 429 Too Many Requests with a Retry-After header. See Reference for details.
Reference
Endpoints, error codes, rate limits — everything in one scannable page.
Quick scannable reference for everything covered elsewhere in the guide.
Endpoint summary
All endpoints are at https://api-uksouth-01.qflowhub.io and require both the Authorization: Bearer <token> and X-Qflow-Client-Key: <key> headers.
Attendees
| Method | Path | Purpose |
|---|---|---|
POST |
/api/guest |
Create attendee. With optional commsTemplateId + correlationId for atomic create-and-send |
GET |
/api/guest/{id} |
Fetch one attendee |
PUT |
/api/guest |
Update attendee |
DELETE |
/api/guest |
Delete attendee (body: {"id": "<guid>"}) |
POST |
/api/guest/checkin |
Check an attendee in |
POST |
/api/guest/checkout |
Check an attendee out |
GET |
/api/guests/{eventId} |
List all attendees for an event |
POST |
/api/guests/{eventId} |
Bulk create attendees (200/call cap) |
Events
| Method | Path | Purpose |
|---|---|---|
POST |
/api/event |
Create event |
GET |
/api/event/{id} |
Fetch one event |
PUT |
/api/event |
Update event |
DELETE |
/api/event |
Delete event |
GET |
/api/events?start=<iso>&end=<iso> |
List events between dates |
Comms — templates
| Method | Path | Purpose |
|---|---|---|
POST |
/api/comms/template |
Create or upsert a template |
GET |
/api/comms/template/{id} |
Fetch one template |
GET |
/api/comms/templates?eventId=<guid> |
List templates for an event |
POST |
/api/comms/template/delete |
Delete a template |
Comms — sending
| Method | Path | Purpose |
|---|---|---|
POST |
/api/comms/send |
Two-step send to existing attendee |
Comms — webhooks
| Method | Path | Purpose |
|---|---|---|
GET |
/api/comms/webhook |
Read configured URL |
POST |
/api/comms/webhook |
Set URL (returns secret once) |
POST |
/api/comms/webhook/rotate |
Generate new secret |
POST |
/api/comms/webhook/test |
Fire a synthetic comms.test event |
POST |
/api/comms/webhook/delete |
Clear configuration |
Comms — custom domain
| Method | Path | Purpose |
|---|---|---|
POST |
/api/comms/domain |
Create domain auth — body: {"domain": "yourbrand.com"} |
GET |
/api/comms/domain |
Read current state + DNS records |
POST |
/api/comms/domain/verify |
Validate DNS at SendGrid |
POST |
/api/comms/domain/activate |
Re-enable after deactivate |
POST |
/api/comms/domain/deactivate |
Disable temporarily |
POST |
/api/comms/domain/delete |
Remove entirely |
Rate limits
Two layers, both apply — your request must pass both.
| Layer | Scope | Limit | On excess |
|---|---|---|---|
| Cloudflare edge | Across the whole api-uksouth-01.qflowhub.io host, per source IP |
60 requests / minute | HTTP 429, source IP blocked for 60 seconds |
| In-app | Per gated user + source IP | 60 requests / minute | HTTP 429 rate_limit with Retry-After header |
For typical atomic-send flows (one signup → one API call), 60/min is well above any sustained rate. For bulk ticket-batch flushes, pace your calls at ≤1 per second and you'll never trip either limit.
Bulk endpoints (e.g. POST /api/guests/{eventId}) cap at 200 records per call in the request body. Larger payloads return 400 Bad Request — split into 200-record chunks and pace.
Error responses
| Status | Body / error code | Meaning |
|---|---|---|
400 |
(varies) | Validation error — missing required field, malformed GUID, invalid JSON |
401 |
{"message":"Authorization has been denied for this request."} |
Bearer token missing, invalid, or expired |
401 |
client_key_invalid |
Missing or wrong X-Qflow-Client-Key header |
403 |
comms_api_not_enabled |
Account is not enabled for the Comms API — contact support |
403 |
trust_required |
Sender verification (KYC) has not been completed |
404 |
(none) | Event / template / attendee / webhook not found, or not owned by you |
409 |
domain_already_configured |
Tried to create a custom domain when one is already set — delete first |
409 |
domain_not_verified |
/activate called before any successful /verify |
411 |
(none) | Empty-body POST without Content-Length: 0 — affects /webhook/{rotate,test,delete} and /domain/{verify,activate,deactivate,delete}. With curl, pass -d '' |
422 |
fromaddress_domain_not_verified |
Template fromAddress uses an unauthenticated domain — verify it via POST /api/comms/domain |
422 |
previous_send_permanently_failed |
Recipient cannot be emailed (bounced / dropped / spam / unsubscribed) |
422 |
spam_check_failed |
Template HTML contains URLs flagged as unsafe |
422 |
sendgrid_create_failed |
SendGrid rejected the domain create — see message |
429 |
rate_limit |
Too many requests — see Retry-After header |
502 |
fromaddress_validation_unavailable |
Couldn't reach SendGrid to verify fromAddress — retry shortly |
500 |
(varies) | Unexpected server error — retry the request, contact support if persistent |
Webhook events
| Event | Trigger |
|---|---|
comms.test |
POST /api/comms/webhook/test |
comms.sent |
Email accepted by SendGrid |
comms.delivered |
Recipient mail server confirmed |
comms.opened |
Recipient (or their email client) loaded the email |
comms.bounced |
Recipient mail server rejected — auto-suppressed for future sends |
comms.dropped |
SendGrid dropped before delivery (suppression-list, etc.) |
comms.spam_reported |
Recipient marked as spam — auto-suppressed |
comms.unsubscribed |
Recipient hit the unsubscribe link |
comms.failed |
Internal failure during send pipeline (rare) |
Versioning
This is the v1 surface. Breaking changes will be announced at least 90 days in advance via developer email. Additive changes (new event types, new optional fields) ship without notice — build your client to ignore unknown fields and event types.
Support
Stuck on something this guide doesn't cover, or hitting an error that doesn't make sense? Email support@qflow.io with:
- Your account / username
- The endpoint you're calling
- The full request (redact secrets)
- The full response (status + body + relevant headers)
- Your
correlationIdif you've sent one — we'll trace through our logs
Real human on the other end. Usually <24h response time on weekdays.