Getting started

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 template
  • POST /api/comms/webhook — register your delivery-event receiver
  • POST /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

  1. Authentication

    Get your first bearer token. See Authentication.

  2. Quick start

    End-to-end walkthrough — token to first email in five steps. See Quick start.

  3. Templates

    Author the HTML email body with merge tokens. See Email templates.

  4. Webhooks

    Set up a receiver, verify HMAC signatures, observe deliveries. See Webhooks.

  5. Sending

    Two send patterns — atomic create+send via /api/guest, or two-step via /api/comms/send. See Sending.

  6. Custom sending domain (optional)

    Use your own noreply@yourbrand.com instead 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/guest with commsTemplateId
  • ✓ Two-step send via POST /api/comms/send to existing attendees
  • ✓ Webhook signing (HMAC-SHA256) and the full delivery-event chain (comms.sentcomms.deliveredcomms.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.

Getting started

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_in rather 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
Getting started

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"
}
Warning

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:

  1. comms.sent within seconds — Qflow accepted the email and SendGrid acknowledged it
  2. comms.delivered within ~30-90 seconds — recipient mail server confirmed receipt
  3. comms.opened when 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.com instead of the default support@qflow.email
  • Bulk sending patterns — see Sending → Two-step send for resending to attendees that already exist
  • Error catalogue + rate limits — see Reference
Setup

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.

Note

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"
}
Note

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

Warning

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.

Setup

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 correlationId you 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"
}
Warning

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
Note

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 → stringify produces 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 use timingSafeEqual / compare_digest / FixedTimeEquals / hmac.Equal / secure_compare.
  • Hex-decoding the signature first — both X-Qflow-Signature and 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.sentcomms.delivered → (comms.opened if/when they open it). A bounce produces comms.sentcomms.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-Id is reused across retries — dedupe on it.
  • Filtering: today, all comms events go to your single configured URL. Filter on X-Qflow-Event on your receiver if you only care about a subset.
Setup

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

  1. Create the domain auth record

    POST /api/comms/domain with the apex domain (e.g. yourbrand.com). Qflow registers it with SendGrid and returns three DNS records you need to publish.

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

  3. 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)
Note

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:

  1. POST /api/comms/domain/delete to remove the existing one
  2. POST /api/comms/domain with the new domain
  3. 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

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
AtomicPOST /api/guest with commsTemplateId You're creating an attendee and want their welcome email queued in the same call. The most common pattern.
Two-stepPOST /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:

  1. Validate — template exists, attendee belongs to your account, recipient hasn't permanently failed
  2. Render — substitute merge tokens ({{firstname}}, {{barcode}}, etc.) with the attendee's data
  3. Hand off to SendGrid — with the From-address resolved (custom domain if you have one, default otherwise)
  4. Fire comms.sent to your webhook — the moment SendGrid acknowledges the message
  5. 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

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 correlationId if you've sent one — we'll trace through our logs

Real human on the other end. Usually <24h response time on weekdays.