Comms API

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

Optional features

Read in this order

  1. Templates

    Author the email content. See Templates.

  2. Webhooks

    Register a receiver URL and verify HMAC signatures. See Webhooks.

  3. Sending

    Trigger sends — atomic or two-step. See Sending.

  4. (Optional) Custom domain

    Authenticate your own sending domain. See Custom domain.

For request/response schemas + try-it, see the auto-generated API Reference.

Comms API

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

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
  }'
Note
  • 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.
Warning

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
  }'
Note
  • emailBody supports the same merge tokens as html and subject{{firstname}}, {{event}}, {{barcode}}, etc.
  • Plain text or simple HTML — both work. No editor required on your side; this is just a string.
  • Ignored when attachPdf is false — in that case html is the email body as usual.
  • Like attachPdf, null/omitted leaves the existing value alone on upserts. Send an empty string "" to clear it.
  • When attachPdf is true and emailBody is null, the email body falls back to Please 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
}
Warning

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.

Try it
POST/template GET/template/{id} GET/templates POST/template/delete
Comms API

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.

Note

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

  1. Attendee row created

    Or returned as-is if your id is already in use — see Idempotency.

  2. Synchronous validation

    Template exists, belongs to your account, your account is verified for sending.

  3. Email queued

    Hangfire picks up the send, renders merge tokens, hands to SendGrid.

  4. Lifecycle webhooks fire

    comms.sent when 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 same id return 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.
Warning

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.

Try it
POST/send
Comms API

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.

Note

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

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.

Warning

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
Note

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

  1. Synchronous validation

    25-attendee cap, event ownership, template + event match, mode/IsPDF compatibility, suppression check on toEmail, paywall check for N units.

  2. Atomic transaction

    Creates N Attendee rows + 1 BatchSends row + N membership rows + 1 outbox row in one DB transaction.

  3. Hangfire job queued

    The send work hands off to our qflow_invites_queue background 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.

  4. Render + SendGrid

    Worker renders the PDF (or HTML body for index mode), creates N AttendeeInvitation rows for audit, sends one email via SendGrid.

  5. Lifecycle webhooks

    comms.batch.sent fires 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.

Try it
POST/batch/send POST/batch/test
Comms API

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

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
Note

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)

  1. Read the raw body

    Don't reparse and re-stringify the JSON — the signature is over the exact bytes we sent. Most frameworks let you read raw bytes before JSON parsing.

  2. Parse X-Qflow-Signature

    Split on ,, find t= and v1=.

  3. Replay window check

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

  4. Recompute the HMAC

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

  5. Constant-time compare

    Compare your computed hex to the v1= value using a constant-time function (most crypto libraries provide one). Regular == is vulnerable to timing attacks.

  6. Process or reject

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

Verification examples

const crypto = require('crypto');

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

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

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

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

  const event = JSON.parse(body);
  // ... handle event ...
  res.sendStatus(200);
});
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-Id is reused across retries — dedupe on it. After max retries, the message goes to a dead-letter queue.
  • No subscription/event-type filtering: today, all comms events go to your single webhook URL. Filter on X-Qflow-Event on your receiver.
Try it
GET/webhook POST/webhook POST/webhook/rotate POST/webhook/delete POST/webhook/test
Comms API

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

  1. Create the domain

    POST /comms/v1/domain with your domain. SendGrid issues 3 CNAMEs.

  2. Publish the CNAMEs at your registrar

    Add the records exactly as returned. DNS changes can take minutes to hours to propagate.

  3. Verify and declare your default sender

    POST /comms/v1/domain/verify with { "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)
Note

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.

Note

/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_configureddelete first if you want to switch.

Try it
GET/domain POST/domain POST/domain/verify POST/domain/activate POST/domain/deactivate POST/domain/delete
Comms API

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.