Comms API — Overview
Send transactional emails to attendees you create through the Qflow API, with optional PDF attachments and your own sending domain. Get signed webhooks for every delivery event.
The Comms API is for transactional email — ticket confirmations, registration receipts, custom invitations, "I lost my ticket" resends. You manage the templates and the receiving webhook endpoint; Qflow handles delivery, tracking, bounce / spam compliance, and retries via SendGrid.
URL pattern
All Comms endpoints live under:
https://api.qflowhub.io/comms/v1/<endpoint>
The atomic create-and-send pattern (where you trigger an email as part of guest creation) lives on the Check-in API endpoint:
https://api.qflowhub.io/checkin/v1/api/guest
Every request requires both an OAuth bearer token AND an Ocp-Apim-Subscription-Key header — see Authentication. Comms endpoints additionally require allow-list access (403 comms_api_not_enabled if your user isn't on it — contact support), and sends require a verified client (403 trust_required until Trust='trusted' is set on your user record via qflowhub.io/manage). The two gates are independent: clearing one does not clear the other.
What it does
When you create attendees via the API, you can optionally trigger a templated email send in the same call. Or you can send to attendees that already exist (bulk-imported, created via another endpoint). Either way:
- Substitutes merge tokens like
{{firstname}},{{event}},{{barcode}}from real attendee/event data - Optionally attaches a PDF (rendered from the template's
html) — the email body is sent separately - Optionally sends from your own DKIM-signed domain
- Tracks delivery, opens, bounces and other lifecycle events
- POSTs signed webhooks to your configured URL for every event
- Supports retry-safe idempotency
Three send patterns
Atomic
POST /api/guest with commsTemplateId — create one attendee + queue send in one call.
Two-step
POST /comms/v1/send — send to an attendee that already exists.
Batch
POST /api/guests/{eventId}/batch — atomically create 1-25 attendees + queue one bundled email containing all their tickets (multi-ticket purchases).
Optional features
PDF attachments
Set attachPdf: true on a template — every send attaches a PDF rendered from the template HTML, sent alongside a separate email body.
Custom sending domain
Authenticate your own domain via SendGrid Domain Authentication.
Read in this order
- Templates —
Author the email content. See Templates.
- Webhooks —
Register a receiver URL and verify HMAC signatures. See Webhooks.
- Sending —
Trigger sends — atomic or two-step. See Sending.
- (Optional) Custom domain —
Authenticate your own sending domain. See Custom domain.
For request/response schemas + try-it, see the auto-generated API Reference.
Templates
Stored HTML email bodies + subject lines, with merge tokens. Optional PDF attachment per template.
A template is the email content you send. It's HTML + a subject line + sender details, scoped to a single event. You create a template once, then reference it by id when sending to attendees.
Create a template
curl -X POST 'https://api.qflowhub.io/comms/v1/template' \
-H 'Authorization: Bearer <token>' \
-H 'Ocp-Apim-Subscription-Key: <subscription_key>' \
-H 'Content-Type: application/json' \
-d '{
"id": "<your guid (optional — for idempotent upserts)>",
"eventId": "4ef2891c-1e79-4c27-90b7-e2c20c8b13d9",
"name": "Ticket confirmation v1",
"subject": "Your ticket for {{event}} — {{firstname}}",
"html": "<html><body><h1>Hi {{firstname}}!</h1><p>Your ticket: {{barcode}}</p></body></html>",
"fromAddress": "tickets@yourcompany.com",
"fromName": "Your Company Tickets",
"replyTo": "support@yourcompany.com",
"attachPdf": false
}'
Response (200):
{
"id": "7bc31757-5a82-4d7a-8767-bb508f6d3481",
"eventId": "4ef2891c-1e79-4c27-90b7-e2c20c8b13d9",
"name": "Ticket confirmation v1",
"subject": "Your ticket for {{event}} — {{firstname}}",
"html": "<html>...",
"fromAddress": "tickets@yourcompany.com",
"fromName": "Your Company Tickets",
"replyTo": "support@yourcompany.com",
"attachPdf": false,
"emailBody": null,
"created": "2026-04-30T10:00:00Z"
}
Capture the id — you'll reference it when sending. If you supply your own id in the create request, the call is idempotent: posting the same id twice with different content is treated as an update.
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, and you won't accidentally wipe fromAddress, fromName, replyTo, attachPdf, emailBody, or suppressUnsubscribe.
| 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 and suppressUnsubscribe are bool?; send false to turn 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 domain validation
When you set fromAddress, the domain must match (or be a subdomain of) the custom domain you've authenticated for your account via POST /comms/v1/domain. This stops misconfigured templates from silently failing at SendGrid.
fromAddress value |
Result |
|---|---|
Omitted or null |
No check — uses the account defaultSender from /comms/v1/domain/verify, falling back to the global Qflow address if no custom domain is verified |
Empty string "" |
No check — clears any previously-set override |
Domain matches your verified custom domain (e.g. events@theirdomain.com when you've verified theirdomain.com) |
✓ saved |
Subdomain of your verified domain (e.g. noreply@mail.theirdomain.com when you've verified theirdomain.com) |
✓ saved |
| Domain you haven't authenticated | 422 fromaddress_domain_not_verified |
| Account has no verified custom domain at all | 422 fromaddress_domain_not_verified |
If the domain check can't run (transient SendGrid lookup failure), the API returns 502 fromaddress_validation_unavailable and you should retry.
Merge tokens
The following are replaced in subject and html per recipient:
| Token | Replaced with |
|---|---|
{{firstname}} |
Attendee's first name |
{{lastname}} |
Attendee's last name |
{{fullname}} |
First + last name |
{{othernames}} |
Middle/other 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}}" /> |
{{plusones}} |
Number of plus-ones (or empty if 0) |
{{guestnotes}} |
Attendee's additionalInfo |
{{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}} |
Sender's company name |
{{rsvplink_yes}}, {{rsvplink_no}} |
RSVP confirmation links |
{{V_1}}, {{V_2}}, … |
Custom field values from the attendee's CustomFields JSON |
{{ticketcount}} |
Total tickets in the delivery — 1 for single sends, N for batch sends. Use in subjects: "Your tickets for {{event}} ({{ticketcount}})" |
{{ticketnumber}} |
1-based index of THIS ticket within a batch. In batch replicate mode renders 1, 2, 3, ... on successive pages so bodies can read "Ticket {{ticketnumber}} of {{ticketcount}}". Always 1 for single sends and batch index renders |
{{ticket_grid}} |
Batch index mode only — expands to a server-styled 3-column grid of every attendee's QR + name + barcode. Stripped in single sends and batch replicate mode |
{{ticket_list}} |
Batch index mode only — same data as {{ticket_grid}} in a single-column vertical layout |
Tokens that don't match (typo, no data) are left literal in the output. The last four ({{ticketcount}} / {{ticketnumber}} / {{ticket_grid}} / {{ticket_list}}) are batch-aware — full context in Batch sending.
PDF attachment
Setting attachPdf: true on the template makes every send from that template attach a PDF rendered from the merge-resolved html. Filename is {template-name}.pdf.
When attachPdf is true, your rich html goes into the PDF, and the email body becomes the short courier note you supply in emailBody (covered next). If you don't supply an emailBody, the body falls back to Please find your invitation attached. — so set emailBody whenever the email itself should say something. (When attachPdf is false, html is the email body as usual and no PDF is generated.)
curl -X POST 'https://api.qflowhub.io/comms/v1/template' \
-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
-d '{
"id": "<your guid>",
"eventId": "<event guid>",
"name": "Ticket Confirmation",
"subject": "Your ticket — {{firstname}}",
"html": "<h1>Hi {{firstname}}!</h1><p>Ticket: {{barcode}}</p><p><img src=\"{{barcode_qr}}\" /></p>",
"attachPdf": true
}'
- Field is
bool?.null(or omitted) leaves the existing flag alone on upserts — partial template updates won't accidentally turn PDF off. - Toggle off any time by reposting the template with
attachPdf: false. - PDF rendering is synchronous — adds ~1–2s per send. Pace bulk flushes accordingly.
If PDF generation fails (rendering server unreachable, malformed HTML), the entire send fails with comms.failed rather than shipping an email without the promised attachment.
Courier-style email body (emailBody)
When attachPdf is true, you often want the email itself to be a short courier note ("Hi Joe, your ticket is attached") while the rich design lives in the PDF. Supply emailBody for that — it becomes the email body, and html is used only for the PDF.
curl -X POST 'https://api.qflowhub.io/comms/v1/template' \
-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
-d '{
"id": "<your guid>",
"eventId": "<event guid>",
"name": "Ticket Confirmation",
"subject": "Your ticket — {{firstname}}",
"html": "<h1>Hi {{firstname}}!</h1><p>Ticket: {{barcode}}</p><p><img src=\"{{barcode_qr}}\" /></p>",
"emailBody": "<p>Hi {{firstname}},</p><p>Your ticket for {{event}} is attached as a PDF. See you on {{date}}!</p>",
"attachPdf": true
}'
emailBodysupports the same merge tokens ashtmlandsubject—{{firstname}},{{event}},{{barcode}}, etc.- Plain text or simple HTML — both work. No editor required on your side; this is just a string.
- Ignored when
attachPdfisfalse— in that casehtmlis the email body as usual. - Like
attachPdf,null/omitted leaves the existing value alone on upserts. Send an empty string""to clear it. - When
attachPdfistrueandemailBodyisnull, the email body falls back toPlease find your invitation attached.
Suppress the unsubscribe footer
By default, every email sent through the Comms API carries a platform-managed footer with a one-click unsubscribe link. Setting suppressUnsubscribe: true on a template hides that footer for sends from that template.
{
"id": "<your guid>",
"eventId": "<event guid>",
"name": "Member invitation",
"subject": "Your invitation, {{firstname}}",
"html": "<p>...</p>",
"suppressUnsubscribe": true
}
Only use this for relationship audiences you can demonstrate consent for — your own members, customers you've onboarded, ticket-buyers — where the recipient already has an existing relationship with you and a reasonable expectation of receiving this email. Sends to cold lists, purchased lists, or anyone who hasn't explicitly opted in must keep the footer in place to remain compliant with anti-spam law (CAN-SPAM in the US, GDPR / PECR in the UK and EU, CASL in Canada). The footer is the recipient's primary opt-out channel; suppressing it without a lawful basis is a real legal risk for your account.
Like attachPdf, this is a bool? — omit or send null to leave the existing value alone on upserts; send false to explicitly re-enable the footer.
Other template endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/comms/v1/template/{id} |
Fetch a single template |
GET |
/comms/v1/templates?eventId=<guid> |
List templates for an event |
POST |
/comms/v1/template/delete |
Delete a template (body: {"id": "<guid>"}) |
URL safety scanning
Template HTML is automatically scanned by Google Web Risk on creation. Templates containing URLs flagged for malware, phishing, or unwanted software 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.
Sending
Three patterns for triggering an email — atomic create-and-send, two-step send to an existing attendee, or atomic batch create + bundled send.
Three patterns
You can send templated emails in three ways depending on whether the recipient exists and whether you're sending one ticket or many:
A. Atomic
POST /api/guest with commsTemplateId. Single attendee + single email in one call. Use on the ticket-purchase happy path — no window where the guest exists in our DB without their email queued.
B. Two-step
POST /comms/v1/send. Send to an attendee that already exists (bulk-imported, "I lost my ticket" resends, or retries after transient API-side failures).
C. Batch
POST /api/guests/{eventId}/batch. Atomically create 1-25 attendees and queue one bundled email containing all their tickets. Use for "purchaser buys 5 tickets" flows.
Same internal pipeline + same idempotency for all three. Single + two-step fire comms.sent / comms.delivered / etc. (per-attendee). Batch fires comms.batch.sent / comms.batch.delivered / etc. (per-bundle, with an attendees[] array). Pick whichever fits your flow.
This article covers (A) and (B); for (C) see Batch sending.
A. Atomic — create + send
The send is triggered as part of guest creation — single round-trip:
curl -X POST 'https://api.qflowhub.io/checkin/v1/api/guest' \
-H 'Authorization: Bearer <token>' \
-H 'Ocp-Apim-Subscription-Key: <subscription_key>' \
-H 'Content-Type: application/json' \
-d '{
"id": "<your guid for this attendee>",
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@example.com",
"eventId": "<event guid>",
"commsTemplateId": "<template guid>",
"correlationId": "order-2026-0042"
}'
Response (200): the created attendee record.
The id field is yours to choose. It must be a globally-unique GUID. We recommend generating it on your side (one GUID per logical action), because retrying the same call with the same id is idempotent — see Idempotency below.
The send happens asynchronously. You'll receive comms.sent (or comms.failed) at your webhook URL within seconds, followed by comms.delivered / comms.opened etc. as the email lifecycle progresses.
What happens internally
- Attendee row created —
Or returned as-is if your
idis already in use — see Idempotency. - Synchronous validation —
Template exists, belongs to your account, your account is verified for sending.
- Email queued —
Hangfire picks up the send, renders merge tokens, hands to SendGrid.
- Lifecycle webhooks fire —
comms.sentwhen SendGrid accepts. As lifecycle events come back from SendGrid (comms.delivered,comms.opened,comms.bounced...) we forward them.
All webhooks for one send share the same attendeeInvitationId so you can match them to the original send.
B. Two-step — send to an existing attendee
Use this whenever the attendee already exists in Qflow — for first sends to bulk-imported guests, for resends ("I lost my ticket" / customer requests duplicate), and for retries after transient failures.
curl -X POST 'https://api.qflowhub.io/comms/v1/send' \
-H 'Authorization: Bearer <token>' \
-H 'Ocp-Apim-Subscription-Key: <subscription_key>' \
-H 'Content-Type: application/json' \
-d '{
"attendeeId": "<existing attendee guid>",
"templateId": "<template guid>",
"correlationId": "resend-2026-0042"
}'
Response (200) — send queued:
{
"status": "queued"
}
The send is asynchronous, so the response carries only the queue status. The attendeeInvitationId that ties events back to this send arrives on the webhook payloads, not here — correlate on that id (and your correlationId). Watch for the usual lifecycle webhooks (comms.sent, comms.delivered, etc.) at your registered webhook URL.
When POST /comms/v1/send rejects
The endpoint inspects the most recent AttendeeInvitation for this (attendee, template) pair. If the previous attempt is in a state where retrying won't help, you get 422 with a structured body — sending again would just hit the same wall.
| Previous state | Sends? | Why |
|---|---|---|
| No previous attempt | ✓ | First send |
| Delivered successfully | ✓ | New attempt — "I lost my ticket" use case |
| Transient API-side failure (we never reached SendGrid) | ✓ | Likely temporary; retry is sensible |
bounced |
✗ | Recipient mail server rejected the address — won't change |
dropped (after delivery to SendGrid) |
✗ | SendGrid suppression / hard fail |
spam_reported |
✗ | Recipient marked as spam — anti-spam policy blocks resend |
unsubscribed |
✗ | Compliance — recipient has opted out |
422 body shape:
{
"error": "previous_send_permanently_failed",
"message": "Previous send bounced — resending will not help.",
"failureType": "bounced",
"lastFailureAt": "2026-04-29T20:43:55Z",
"lastFailureReason": "550 5.1.1 No such user"
}
failureType is one of: bounced, dropped, spam_reported, unsubscribed.
Idempotency
The caller-supplied id GUID is the idempotency key. Generate one per logical action (e.g. one per ticket purchase) and reuse it on any retry of the same logical action.
- Same
id→ no duplicate send. The first call creates the attendee + queues the send. Subsequent calls with the sameidreturn the existing attendee with no new send. - Different
id→ new send. If you need to re-send to the same person for a separate logical action, generate a new GUID.
Don't reuse id across different people. Always generate a fresh GUID per logical action. The id is your idempotency token, not a customer reference (use correlationId for that).
correlationId
A free-form, opaque string you supply per send — keep it short (an order id or ticket reference). We echo it back verbatim in every webhook event for that send (data.correlationId). Use it to correlate events to your own records without having to store our GUIDs.
It's optional. If you don't supply one, correlationId is null in the webhook payload.
From-address resolution
At send time we pick the From address with this fallback chain:
template.fromAddress → account defaultSender (verified custom domain) → global Qflow address
The middle tier is set when you call POST /comms/v1/domain/verify. Once a custom domain is verified you cannot silently fall back to the global Qflow address — defaultSender is required at verify time precisely to make that guarantee hold.
For most integrators: set defaultSender once at verify, leave fromAddress off your templates, and every send routes through your domain automatically. Override fromAddress per-template only when you need a different mailbox for that specific template.
Batch sending
Atomic create + send for purchasers buying multiple tickets — one call creates N attendees and delivers one bundled email containing all their tickets.
When to use
Use the batch endpoint when one purchaser buys multiple tickets. Every ticket becomes its own attendee record (with its own barcode, its own check-in identity, its own lifecycle), but they're delivered as a single multi-page PDF in a single email to the purchaser's address.
Single ticket
Keep using POST /api/guest with commsTemplateId, or POST /api/guests/{eventId}/batch with an array of one. Same outcome either way.
Multiple tickets, one purchaser
POST /api/guests/{eventId}/batch with the full attendees array. One email, one PDF, one webhook lifecycle.
The batch endpoint works for 1 ticket too — you don't need to maintain two code paths if a unified integration is simpler on your side.
One email per batch — but each ticket inside is its own record. Every batch is delivered as a single email to the batch's toEmail. The attendees in the array become separate guest records with distinct barcodes and distinct check-in identities — but whatever names you put on them is your choice: same name on all 5 (common for "purchaser bought 5 tickets, no guest names collected"), or different names per ticket (when you've captured each guest's details at checkout), or any mix. Per-attendee email is metadata only and never used as a delivery target.
If you want separate emails to separate ticket holders (each guest gets their own email at their own address), use single-ticket sends instead: one POST /api/guest + commsTemplateId per recipient.
The endpoint
curl -X POST 'https://api.qflowhub.io/checkin/v1/api/guests/<event guid>/batch' \
-H 'Authorization: Bearer <token>' \
-H 'Ocp-Apim-Subscription-Key: <subscription_key>' \
-H 'Content-Type: application/json' \
-d '{
"batchId": "<your guid for this purchase>",
"templateId": "<template guid>",
"pdfMode": "replicate",
"filenamePattern": "{{event}}-tickets.pdf",
"toEmail": "purchaser@example.com",
"toName": "Jane Smith",
"correlationId": "order-2026-0042",
"attendees": [
{ "firstName": "Jane", "lastName": "Smith", "email": "jane@example.com" },
{ "firstName": "Bob", "lastName": "Smith", "email": "bob@example.com" },
{ "firstName": "Carol","lastName": "Smith" },
{ "firstName": "Dave", "lastName": "Smith" },
{ "firstName": "Eve", "lastName": "Smith" }
]
}'
Response (200) — work queued:
{
"batchId": "<your guid>",
"status": "queued",
"attendeeCount": 5,
"attendeeIds": ["<guid1>", "<guid2>", "<guid3>", "<guid4>", "<guid5>"],
"pdfMode": "replicate",
"resolvedFilename": "spring-2026-festival-tickets.pdf"
}
Five attendees were created and a single PDF send was queued. Watch your configured webhook URL for comms.batch.sent followed by comms.batch.delivered once the recipient mail server confirms.
PDF modes
| Mode | Output | When to use |
|---|---|---|
replicate |
Multi-page PDF, one ticket per page (full template rendered N times with each attendee's data). | The default for "one ticket per page, full ticket on each page" delivery. PDF only — requires the template's attachPdf: true. |
index |
Template renders once (with the lead attendee's merge data); {{ticket_grid}} or {{ticket_list}} expands to a server-styled grid/list of all attendees' barcodes inline. Works in PDF or HTML email body. |
When you want a single "master ticket" with a guest list, rather than one page per ticket. |
single |
Template renders once with the first attendee's data; tokens like {{ticket_grid}} are stripped. |
Edge case — collapsing a batch back to a single render. Rarely needed; the batch endpoint with an array of one is usually cleaner. |
Picking replicate with a template that has attachPdf: false returns 400 replicate_requires_pdf. Either flip the template to PDF mode via POST /comms/v1/template with attachPdf: true, or use index mode which works in HTML email bodies too.
Index mode tokens
Two new merge tokens, available in index mode only:
| Token | What it expands to |
|---|---|
{{ticket_grid}} |
A server-rendered table with QR + name + barcode per cell. 3 columns; paginates naturally in PDF; renders as a <table> in HTML email. |
{{ticket_list}} |
Same data, 1 column (QR on the left, name + barcode on the right). Better for narrow email clients or accessibility-leaning templates. |
Both are stripped in replicate mode (with a warning surfaced via the test endpoint). Both render to empty in single sends — they only have meaning when there's a batch of attendees.
{{ticketcount}} and {{ticketnumber}}
Two new merge tokens available in all modes, including single sends:
| Token | What it expands to |
|---|---|
{{ticketcount}} |
Total tickets in the bundle (1 for singles, N for batches). Useful in subject lines and courier bodies. |
{{ticketnumber}} |
1-based index of THIS ticket within the bundle. In replicate mode renders 1, 2, 3... on successive pages so each ticket reads "Ticket 1 of 5", "Ticket 2 of 5", etc. Always 1 in single sends and in index-mode renders (since the template renders once). |
Subject: Your tickets for {{event}} ({{ticketcount}})
Renders: Your tickets for Spring 2026 Festival (5)
Ticket line: Ticket {{ticketnumber}} of {{ticketcount}}
Page 1: Ticket 1 of 5
Page 2: Ticket 2 of 5
...
Idempotency
The caller-supplied batchId GUID is the idempotency key. Same batchId on a retry returns the existing batch state — no duplicate attendees, no duplicate sends.
Generate one batchId per logical purchase, not per call. Re-using a batchId across two genuinely different purchases will silently return the first batch's state instead of creating the second.
Resending a batch
If a customer reports they didn't receive the email, call POST /comms/v1/batch/send with the original batchId:
curl -X POST 'https://api.qflowhub.io/comms/v1/batch/send' \
-H 'Authorization: Bearer <token>' \
-H 'Ocp-Apim-Subscription-Key: <subscription_key>' \
-H 'Content-Type: application/json' \
-d '{
"batchId": "<original batch guid>",
"allowResend": true,
"correlationId": "resend-2026-0042"
}'
allowResend: true is required to force a fresh send when the batch is already in Sent state — without it, you get 409 batch_already_sent with the existing state.
The PDF is re-rendered (so any merge-field updates land), the same toEmail is used, and N new AttendeeInvitation rows are created for audit. The mode is locked at first send — you can't switch from replicate to index on a resend.
Preview / test (no DB writes, no paywall)
POST /comms/v1/batch/test renders the same pipeline with synthetic attendees for staging validation. No Attendee rows are created, no paywall is hit, no integrator webhooks fire by default.
curl -X POST 'https://api.qflowhub.io/comms/v1/batch/test' \
-H 'Authorization: Bearer <token>' \
-H 'Ocp-Apim-Subscription-Key: <subscription_key>' \
-H 'Content-Type: application/json' \
-d '{
"eventId": "<event guid>",
"templateId": "<template guid>",
"pdfMode": "replicate",
"filenamePattern": "preview.pdf",
"testAttendees": 5,
"testRecipient": "you@yourcompany.com",
"sendEmail": false,
"fireWebhook": false
}'
Response includes the rendered PDF (pdfBase64), its size, sanitised filename, and any warnings. When testAttendees ≤ 25, an extra debug block lists the synthetic attendees + render timing.
Set sendEmail: true (with a real testRecipient) to actually receive the rendered email at your inbox — handy when verifying client-side rendering before going live. Set fireWebhook: true to emit a single comms.batch.test event to your configured webhook so you can validate signature handling.
Saving the rendered PDF locally
The pdfBase64 field carries the rendered PDF as a base64 string — decode it to get a regular .pdf file. From the terminal:
curl -sk -X POST 'https://api.qflowhub.io/comms/v1/batch/test' \
-H 'Authorization: Bearer <token>' \
-H 'Ocp-Apim-Subscription-Key: <subscription_key>' \
-H 'Content-Type: application/json' \
-d '{"eventId":"<event guid>","templateId":"<template guid>","pdfMode":"replicate","testAttendees":3,"sendEmail":false}' \
| python3 -c "import sys,json,base64; r=json.load(sys.stdin); open(r['resolvedFilename'],'wb').write(base64.b64decode(r['pdfBase64'])); print('wrote', r['resolvedFilename'], r['sizeBytes'], 'bytes')"curl -sk -X POST 'https://api.qflowhub.io/comms/v1/batch/test' \
-H 'Authorization: Bearer <token>' \
-H 'Ocp-Apim-Subscription-Key: <subscription_key>' \
-H 'Content-Type: application/json' \
-d '{"eventId":"<event guid>","templateId":"<template guid>","pdfMode":"replicate","testAttendees":3,"sendEmail":false}' \
| jq -r '.pdfBase64' | base64 -d > tickets.pdf(On macOS BSD base64, use -D instead of -d. GNU base64 accepts both.)
Triggering a browser download from your own UI
If your dashboard renders a "preview" button that calls /batch/test and wants to let the operator save the PDF, decode pdfBase64 to a Blob client-side and trigger a download:
async function previewBatchPdf(req) {
const res = await fetch('/comms/v1/batch/test', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Ocp-Apim-Subscription-Key': subKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...req, sendEmail: false })
});
const { pdfBase64, resolvedFilename } = await res.json();
const bytes = Uint8Array.from(atob(pdfBase64), c => c.charCodeAt(0));
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
const a = Object.assign(document.createElement('a'), { href: url, download: resolvedFilename });
a.click();
URL.revokeObjectURL(url);
}
Or render inline via <iframe src="data:application/pdf;base64,..."> for an in-page preview without the download.
Limits + errors
| Code | Error | Cause |
|---|---|---|
400 |
batch_too_large |
More than 25 attendees in one batch — split into multiple batches |
400 |
replicate_requires_pdf |
pdfMode: "replicate" against a template with attachPdf: false — switch to index, or flip the template to PDF mode |
403 |
paywall_exceeded |
N attendees would exceed your remaining paywall capacity — no rows created |
403 |
Forbidden |
Caller doesn't own the event, or comms-api access isn't enabled for the account |
404 |
template/event not found | Self-explanatory |
409 |
batch_already_sent |
Resending a batch in Sent state without allowResend: true |
422 |
recipient_blocked |
toEmail has previously bounced / been marked spam / unsubscribed on a prior send |
PDF size is enforced during rendering, not at submit time. The bundle is rendered asynchronously after the POST returns 200 queued, so an over-limit PDF doesn't fail the request. Instead the send fails afterwards and you receive a comms.batch.failed webhook carrying error: "pdf_too_large" (we cap at 25 MB; SendGrid's hard limit is 30 MB). Reduce the attendee count or switch to index mode.
Webhooks
One batch = one delivery lifecycle = one webhook per event type. There is no per-attendee fan-out for batch sends, which would otherwise flood your receiver with redundant events.
| Event | Fires when |
|---|---|
comms.batch.sent |
SendGrid accepted the bundled email |
comms.batch.delivered |
Recipient mail server confirmed receipt |
comms.batch.opened |
Recipient opened the email |
comms.batch.bounced |
Recipient mail server rejected the address |
comms.batch.failed |
Render / paywall / SendGrid pipeline error before send |
comms.batch.dropped |
SendGrid drop (suppression list / hard fail) |
comms.batch.spam_reported |
Recipient marked the email as spam |
comms.batch.unsubscribed |
Recipient unsubscribed via SendGrid footer |
comms.batch.test |
Synthetic event from POST /comms/v1/batch/test with fireWebhook: true |
Payload shape:
{
"id": "<webhook uuid>",
"type": "comms.batch.sent",
"createdAt": "2026-04-29T10:23:00.000Z",
"data": {
"batchId": "<your batch guid>",
"templateId": "<template guid>",
"status": "sent",
"pdfMode": "replicate",
"recipientEmail": "purchaser@example.com",
"attendees": [
{ "attendeeId": "<guid1>", "attendeeInvitationId": "<guid>", "barcode": "ABC..." },
{ "attendeeId": "<guid2>", "attendeeInvitationId": "<guid>", "barcode": "DEF..." }
],
"error": null,
"correlationId": "order-2026-0042"
}
}
Like single-send webhooks, batch events use the same { id, type, createdAt, data } envelope — the batch-specific fields live under data. The data.attendees array preserves the order you submitted on the original POST /api/guests/{eventId}/batch.
Cancelling a batch
Each attendee in a batch is a discrete record — the multi-page PDF is just a delivery wrapper. Use the existing bulk-block endpoint to cancel all tickets in one call:
curl -X POST 'https://api.qflowhub.io/checkin/v1/api/guests/block/<eventId>' \
-H 'Authorization: Bearer <token>' \
-H 'Ocp-Apim-Subscription-Key: <subscription_key>' \
-d '{
"guestIds": ["<guid1>", "<guid2>", "<guid3>", "<guid4>", "<guid5>"],
"reason": "Refunded"
}'
At gate-scan, every blocked barcode triggers the same blocked-attendee behaviour you see today — scanner shows the block reason. The delivery mechanism (single vs bundled) doesn't change the data model, so block, refund, lookup, and check-in all work the same as for single tickets.
What happens internally
- Synchronous validation —
25-attendee cap, event ownership, template + event match, mode/IsPDF compatibility, suppression check on
toEmail, paywall check for N units. - Atomic transaction —
Creates N
Attendeerows + 1BatchSendsrow + N membership rows + 1 outbox row in one DB transaction. - Hangfire job queued —
The send work hands off to our
qflow_invites_queuebackground worker. A recurring outbox sweeper covers the case where the process crashes between the DB commit and the queue handoff — no send is ever lost. - Render + SendGrid —
Worker renders the PDF (or HTML body for index mode), creates N
AttendeeInvitationrows for audit, sends one email via SendGrid. - Lifecycle webhooks —
comms.batch.sentfires when SendGrid accepts. Delivery events from SendGrid (comms.batch.delivered, etc.) fan out as they arrive.
All webhooks for one batch share the same batchId so you can match them against your records. Per-attendee correlation is via the attendees[].attendeeInvitationId array in the payload.
Webhooks
Signed HTTP POSTs from Qflow to your endpoint for every email lifecycle event. HMAC-SHA256 verification with replay protection.
Webhooks are how Qflow tells you what happened to an email after you triggered the send. Configure one URL per account; all comms events for your account arrive there.
Register a webhook
curl -X POST 'https://api.qflowhub.io/comms/v1/webhook' \
-H 'Authorization: Bearer <token>' \
-H 'Ocp-Apim-Subscription-Key: <subscription_key>' \
-H 'Content-Type: application/json' \
-d '{"url": "https://your-app.com/webhooks/qflow"}'
Response (shown once — store the secret immediately):
{
"url": "https://your-app.com/webhooks/qflow",
"secret": "0d5124eefa6ed9ae1fece694f70c3c04e48ccb73c67f313548629220cfb61252"
}
The plaintext secret is shown exactly once at creation. We store it AES-encrypted and have no way to recover the plaintext. If you lose it, call POST /comms/v1/webhook/rotate to get a new one (and update your verifier — the old one stops working immediately).
Other webhook endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/comms/v1/webhook |
Read configured URL (no secret) |
POST |
/comms/v1/webhook/rotate |
Generate new secret, old stops working |
POST |
/comms/v1/webhook/test |
Fire a synthetic comms.test event through the full signing pipeline — use during integration to verify your verifier works |
POST |
/comms/v1/webhook/delete |
Clear webhook configuration |
Empty-body POSTs need an explicit Content-Length: 0. /rotate, /test, and /delete take no body. Most HTTP clients handle this automatically, but raw curl returns 411 Length Required unless you pass -d '' (or --data '') so curl sends a zero-length body and the header that goes with it.
# Wrong — IIS rejects with 411 Length Required
curl -X POST -H 'Authorization: Bearer ...' \
https://api.qflowhub.io/comms/v1/webhook/rotate
# Right — explicit empty body, gets 200
curl -X POST -H 'Authorization: Bearer ...' \
-d '' \
https://api.qflowhub.io/comms/v1/webhook/rotate
Verifying signatures (HMAC)
Every webhook is HMAC-SHA256 signed with your secret. You must verify the signature on receipt to prevent forgery — your webhook URL is on the public internet, and without verification you can't tell our events from anyone else's POST.
Headers
X-Qflow-Signature: t=<unix-timestamp>,v1=<64-char hex>
X-Qflow-Webhook-Id: <uuid>
X-Qflow-Event: comms.sent | comms.delivered | comms.bounced | ...
Content-Type: application/json
What HMAC is (mental model)
HMAC mixes a shared secret into a hash of the request body, producing a fingerprint only someone who knows the secret could have produced. Think wax seal on a letter — anyone can read the letter, but only someone with your seal could have made the imprint.
The timestamp is included in the signed payload so an attacker who captures a valid request can't replay it tomorrow. Your receiver rejects anything older than 5 minutes.
How we sign (sender side, for context)
unixTime = current Unix time in seconds
body = raw JSON we're about to POST
signed = unixTime + "." + body ← string concat, period as delimiter
digest = HMAC-SHA256(secret, signed) ← raw 32 bytes
hex = digest as lowercase hex (64 chars)
header = "t=" + unixTime + ",v1=" + hex
The v1= prefix lets us version the algorithm. Accept any v1= for now; if we ship v2= later, accept the highest version your code knows.
How to verify (receiver side)
- Read the raw body —
Don't reparse and re-stringify the JSON — the signature is over the exact bytes we sent. Most frameworks let you read raw bytes before JSON parsing.
- Parse X-Qflow-Signature —
Split on
,, findt=andv1=. - Replay window check —
Compare
tto your current Unix time. Reject if difference > 300 seconds. - Recompute the HMAC —
Build
signed = t + "." + bodyexactly as we did, thenHMAC-SHA256(yourSecret, signed), hex-encode. - Constant-time compare —
Compare your computed hex to the
v1=value using a constant-time function (most crypto libraries provide one). Regular==is vulnerable to timing attacks. - Process or reject —
If it passes, process the event. If not, return 401 and ignore.
Verification examples
const crypto = require('crypto');
app.post('/qflow/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sigHeader = req.header('X-Qflow-Signature') || '';
const parts = Object.fromEntries(sigHeader.split(',').map(p => p.split('=')));
const t = parseInt(parts.t, 10);
const v1 = parts.v1 || '';
if (!t || Math.abs(Math.floor(Date.now()/1000) - t) > 300) return res.sendStatus(401);
const body = req.body.toString('utf8');
const expected = crypto
.createHmac('sha256', process.env.QFLOW_WEBHOOK_SECRET)
.update(`${t}.${body}`)
.digest('hex');
const ok = expected.length === v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(body);
// ... handle event ...
res.sendStatus(200);
});import hmac, hashlib, time, os
from flask import request, abort
SECRET = os.environ['QFLOW_WEBHOOK_SECRET'].encode()
@app.post('/qflow/webhook')
def qflow_webhook():
sig = request.headers.get('X-Qflow-Signature', '')
parts = dict(p.split('=', 1) for p in sig.split(','))
t = int(parts.get('t', 0))
v1 = parts.get('v1', '')
if abs(int(time.time()) - t) > 300:
abort(401)
body = request.get_data() # raw bytes — important
expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(401)
event = request.get_json()
# ... handle event ...
return '', 200[HttpPost("qflow/webhook")]
public async Task<IActionResult> QflowWebhook()
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var sig = Request.Headers["X-Qflow-Signature"].ToString();
var parts = sig.Split(',').Select(p => p.Split('=', 2))
.ToDictionary(p => p[0], p => p[1]);
if (!long.TryParse(parts.GetValueOrDefault("t"), out var t) ||
Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300)
return Unauthorized();
var v1 = parts.GetValueOrDefault("v1", "");
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_secret));
var expectedBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{body}"));
var expectedHex = Convert.ToHexString(expectedBytes).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expectedHex),
Encoding.UTF8.GetBytes(v1)))
return Unauthorized();
// ... handle event ...
return Ok();
}Common mistakes
- Re-stringifying the JSON before HMAC. The signature is over our exact bytes, including whitespace. Read the raw body.
- Using
==to compare signatures. Use a constant-time compare. Timing attacks are real. - Skipping the timestamp check. Without the replay window, the signature alone doesn't protect against captured requests.
- Logging the secret. Mask it like an API key.
- Not deduping
X-Qflow-Webhook-Id. We reuse the same id across retries — dedupe on it so you don't process the same event twice. - Treating delivery as guaranteed. We retry on 5xx, but if your server is down for hours you'll miss events. For critical state, reconcile by polling our API too.
Test your verifier
POST /comms/v1/webhook/test fires a synthetic comms.test event through the full signing pipeline. Use it during integration to confirm your verification works before any real send.
Event types
| Type | When it fires |
|---|---|
comms.sent |
Send job handed the email to SendGrid successfully |
comms.failed |
Send job threw before SendGrid accepted (transient or permanent) |
comms.delivered |
SendGrid confirmed delivery to recipient mail server |
comms.opened |
Recipient opened the email (one event per open — multiple possible) |
comms.bounced |
Recipient mail server rejected the message |
comms.dropped |
SendGrid dropped (suppression list, invalid address, etc.) |
comms.spam_reported |
Recipient marked as spam |
comms.unsubscribed |
Recipient clicked unsubscribe |
comms.test |
Generated by POST /comms/v1/webhook/test — safe to ignore |
Batch sends (POST /api/guests/{eventId}/batch) fire the same lifecycle with a comms.batch.* prefix and a different payload shape — one event per delivery transition for the whole bundle, with an attendees[] array carrying per-ticket correlation:
| Type | When it fires |
|---|---|
comms.batch.sent |
Bundled email accepted by SendGrid |
comms.batch.failed |
Batch render / send pipeline error before SendGrid acceptance |
comms.batch.delivered |
Recipient mail server confirmed receipt of the bundled email |
comms.batch.opened |
Recipient opened the bundled email |
comms.batch.bounced |
Recipient mail server rejected — bundle recipient auto-suppressed |
comms.batch.dropped |
SendGrid dropped the bundled send |
comms.batch.spam_reported |
Recipient marked as spam — auto-suppressed |
comms.batch.unsubscribed |
Recipient unsubscribed |
comms.batch.test |
Synthetic event from POST /comms/v1/batch/test with fireWebhook: true |
See Batch sending → Webhooks for the full batch payload shape (which carries batchId, pdfMode, recipientEmail, and the attendees[] correlation array).
Filter on X-Qflow-Event (or the top-level type in the body) to handle only what you care about.
Webhook payload shape
{
"id": "<webhook uuid>",
"type": "comms.delivered",
"createdAt": "2026-04-29T10:23:00.000Z",
"data": {
"attendeeId": "<the guest GUID you supplied>",
"templateId": "<the template GUID you sent>",
"attendeeInvitationId": "<server-side tracking GUID>",
"status": "delivered",
"error": null,
"correlationId": "<echoed from your POST, if you provided one>"
}
}
error is set only when status == "failed" or status == "dropped".
Operational notes
- Webhook delivery latency: typically sub-second once your account is active. After a quiet period, the first webhook can take up to ~1 minute due to queue polling backoff.
- Replay window: 300 seconds. If your receiver's clock drifts more than ~5 min from ours, valid webhooks may be rejected as stale. Keep clocks NTP-synced.
- Webhook retries: if your endpoint returns 5xx, we retry with exponential backoff up to ~10 attempts. The same
X-Qflow-Webhook-Idis reused across retries — dedupe on it. After max retries, the message goes to a dead-letter queue. - No subscription/event-type filtering: today, all comms events go to your single webhook URL. Filter on
X-Qflow-Eventon your receiver.
Custom domain
Authenticate your own sending domain via SendGrid Domain Authentication. Self-serve via API — create, verify, activate, deactivate, delete.
By default, emails sent through Qflow use shared sending infrastructure. If you want recipients to see emails coming from your own domain (e.g. mail.yourcompany.com), authenticate a domain at SendGrid via the API.
Benefits: improved deliverability (your DKIM signature, your reputation), recipient trust (no via sendgrid.net annotation in Gmail), and full From / Reply-To control.
Lifecycle
- Create the domain —
POST /comms/v1/domainwith your domain. SendGrid issues 3 CNAMEs. - Publish the CNAMEs at your registrar —
Add the records exactly as returned. DNS changes can take minutes to hours to propagate.
- Verify and declare your default sender —
POST /comms/v1/domain/verifywith{ "defaultSender": "events@yourcompany.com" }. SendGrid checks DNS; on success, sending from your domain is automatically activated and the address you supplied becomes the account-wide default From.
After successful verify, every send from the calling user's account routes through the custom domain. Templates can override the From via the per-template fromAddress field (must be under the verified domain); templates that don't override inherit defaultSender automatically.
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/comms/v1/domain |
Create — body: {"domain": "mail.example.com"} |
GET |
/comms/v1/domain |
Read current state (id, domain, verified, enabled, defaultSender, DNS records) |
POST |
/comms/v1/domain/verify |
Validate DNS at SendGrid + set default sender. Body: {"defaultSender": "events@yourcompany.com"} — required. Auto-activates on success. |
POST |
/comms/v1/domain/activate |
Re-enable after a manual deactivate (no DNS recheck) |
POST |
/comms/v1/domain/deactivate |
Disable without losing setup (sends fall back to default sender) |
POST |
/comms/v1/domain/delete |
Remove entirely (clears your domain id, default sender, deletes from SendGrid) |
Empty-body POSTs need an explicit Content-Length: 0. /activate, /deactivate, and /delete take no body. Most HTTP clients handle this automatically, but raw curl returns 411 Length Required unless you pass -d '' (or --data '') so curl sends a zero-length body and the header that goes with it.
# Wrong — IIS rejects with 411 Length Required
curl -X POST -H 'Authorization: Bearer ...' \
https://api.qflowhub.io/comms/v1/domain/activate
# Right — explicit empty body, gets 200
curl -X POST -H 'Authorization: Bearer ...' \
-d '' \
https://api.qflowhub.io/comms/v1/domain/activate
Create
curl -X POST 'https://api.qflowhub.io/comms/v1/domain' \
-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
-d '{"domain": "mail.yourcompany.com"}'
Response (200):
{
"id": 30751683,
"domain": "mail.yourcompany.com",
"verified": false,
"enabled": false,
"dnsRecords": [
{ "type": "cname", "host": "em2037.mail.yourcompany.com", "data": "u44936656.wl157.sendgrid.net", "valid": false },
{ "type": "cname", "host": "s1._domainkey.mail.yourcompany.com", "data": "s1.domainkey.u44936656.wl157.sendgrid.net", "valid": false },
{ "type": "cname", "host": "s2._domainkey.mail.yourcompany.com", "data": "s2.domainkey.u44936656.wl157.sendgrid.net", "valid": false }
]
}
Publish those three CNAMEs at your registrar, then call verify.
Verify
curl -X POST 'https://api.qflowhub.io/comms/v1/domain/verify' \
-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
-d '{"defaultSender": "events@yourcompany.com"}'
defaultSender is required — verifying a domain means you intend to send from it, so we capture the address up-front to guarantee sends never silently fall back to the global Qflow address. The address must be under (or a subdomain of) the domain being authenticated.
Success:
{
"verified": true,
"enabled": true,
"defaultSender": "events@yourcompany.com",
"reasons": []
}
On success the address is persisted as your account default, used whenever a template omits fromAddress. Re-call /verify any time with a new defaultSender to change the default without republishing DNS — verify is idempotent at SendGrid.
Failure (DNS not propagated yet, or wrong record value):
{
"verified": false,
"enabled": false,
"defaultSender": null,
"reasons": [
{ "name": "mail_cname", "valid": false, "reason": "Expected CNAME for \"em2037.mail.yourcompany.com\" to match \"u44936656.wl157.sendgrid.net\"." },
{ "name": "dkim1", "valid": false, "reason": "..." },
{ "name": "dkim2", "valid": false, "reason": "..." }
]
}
defaultSender is null on failure — nothing is persisted until DNS passes. DNS changes can take minutes to hours to propagate; retry verify periodically.
Validation errors
| HTTP | error |
When |
|---|---|---|
| 422 | defaultsender_required |
Body omitted or defaultSender missing/empty |
| 422 | defaultsender_invalid |
Not a valid local@domain address |
| 422 | defaultsender_domain_mismatch |
Domain part of defaultSender is not the verified domain or a subdomain of it |
422 body example:
{
"error": "defaultsender_domain_mismatch",
"message": "defaultSender domain 'wronghost.com' does not match the domain being authenticated ('yourcompany.com'). Use an address under 'yourcompany.com'.",
"domain": "wronghost.com",
"verifiedDomain": "yourcompany.com"
}
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 /comms/v1/domain/delete clears the domain id from your account and deletes the record at SendGrid. After delete, GET /comms/v1/domain returns 404. You can recreate the same domain freshly afterwards.
One domain per account
You can have at most one custom domain per Qflow account at a time. If a domain is configured, POST /comms/v1/domain returns 409 domain_already_configured — delete first if you want to switch.
API Reference
Interactive reference for every Comms endpoint. Authenticate once with your OAuth credentials and subscription key, then call any operation directly from this page.