# CommuniQueue API reference

Base URL `https://api.communiqueue.com`. Header `X-Api-Key` on every request. Live
OpenAPI document: `https://api.communiqueue.com/openapi/v1.json`.

## Operations

| Method and path | Purpose | Success |
|---|---|---|
| `POST /api/v1/notifications/send` | Queue a notification from a template | 202 `SendResponse` |
| `POST /api/v1/notifications/preview` | Render without delivering (email templates only) | 200 `PreviewResponse` |
| `GET /api/v1/api-keys/validate` | Check a key | 200 `ApiKeyValidationResponse`, `isValid` true or false |

## Request body (send and preview)

```
tenantId        guid, required     workspace id
templateId      guid, required
tags            array, required    [{ key, value }] all strings; may be empty
versionNumber   int, optional      omit = live version; must be a published version
emailOptions    object, optional
webhookOptions  object, optional   webhook templates only
```

`emailOptions`:

```
to, cc, bcc          string[]   added to the template's default recipients
overrideRecipients   string[]   replaces every default recipient
fromAddress          string     must be a verified sender; template default otherwise
replyTo              string
attachments          [{ name, content (base64), contentType, cid? }]
customHeaders        { name: value }
trackOpens, trackClicks   bool  override the version's toggles
priority             "Normal" | "High" | "Low"
sensitivity          "Normal" | "Personal" | "Private" | "Confidential"
requestDeliveryReceipt, requestReadReceipt   bool
```

Limits: 1,000 recipients across all four lists; 100 tags; tag key 256 chars; tag value
8,192 chars; 10 attachments, 10 MB each decoded, 25 MB combined, but the request body
is capped at 10 MB by the gateway and answers a bare 413 first; 10 custom headers,
4,096 bytes total, no address, envelope, message or `X-CQ-` headers; attachment names and
content types that are executables or scripts are refused.

`webhookOptions`:

```
urlOverride   string    required for webhook templates; https, port 443, public host
headers       { name: value }   up to 16; no Host, Authorization, Cookie, or credential-like names
```

## Responses

`SendResponse`: `{ accepted: true, correlationId, message }`.

`PreviewResponse`:

```
validationErrors   [{ field, message }]
email
  success          bool
  renderedContent  { subject, body, htmlHead }
  finalBodies      { htmlBody, textBody }
  sender           { fromAddress, fromName, replyTo }
  recipients       { to, cc, bcc, wasOverridden }
  variableMetadata { providedVariables, missingVariables, unusedVariables }
  renderingErrors  [{ templatePart, message }]
  templateMetadata { templateId, versionNumber, emailType, notificationType }
  trackingSettings { trackOpens, trackClicks }
  attachments      [{ name, contentType, sizeBytes, contentId }]
  batchingInfo     { emailType, messageCount, batchingStrategy }
```

## Errors

RFC 7807 `application/problem+json`: `title`, `status`, `detail`, `instance`,
`errorCode`, `correlationId`, `traceId`. Validation failures add `errors` (field to
messages) and `errorCodes` (the specific rules). `413` (body over 10 MB) and `429` (rate
limit) carry no `errorCode`; branch on status for those.

| Code | Status |
|---|---|
| `api_keys.missing` | 401 |
| `api_keys.invalid` | 401 |
| `api_keys.tenant_mismatch` | 403 |
| `notifications.request_missing` | 400 |
| `notifications.tenant_id_required` | 400 |
| `notifications.template_id_required` | 400 |
| `notifications.template_not_found` | 404 |
| `notifications.no_live_version` | 400 |
| `notifications.version_number_invalid` | 400 |
| `notifications.template_version_not_found` | 400 |
| `notifications.template_version_is_draft` | 400 |
| `notifications.tags_required` | 400 |
| `notifications.tag_key_required` / `tag_key_too_long` / `tag_value_too_long` / `too_many_tags` | 400 |
| `notifications.validation_failed` (umbrella, read `errorCodes`) | 400 |
| `notifications.email.to_empty` / `to_invalid` / `cc_*` / `bcc_*` / `reply_to_invalid` / `from_address_invalid` | 400 |
| `notifications.email.override_recipient_empty` / `override_recipient_invalid` / `too_many_recipients` | 400 |
| `notifications.email.custom_headers.too_many` / `too_large` / `name_invalid` / `value_invalid` / `name_not_permitted` / `name_duplicated` | 400 |
| `notifications.attachment.name_required` / `name_too_long` / `name_not_permitted` / `content_required` / `content_invalid_base64` / `content_too_large` / `content_type_required` / `content_type_too_long` / `content_type_not_permitted` / `too_many` / `total_content_too_large` | 400 |
| `notifications.unsupported_notification_type` (preview on a non-email template) | 400 |
| `notifications.webhook_destination_required` | 400 |
| `notifications.webhook_destination_invalid` | 400 |
| `notifications.webhook_delivery_not_available` (plan) | 403 |
| `notifications.monthly_limit_exceeded` (account-wide allowance) | 429 |
| `notifications.monthly_limit_not_configured` | 403 |
| `sender.not_verified` | 422 |

## Rate limits (platform defaults, fixed one-minute windows)

- send and preview: 300 per minute per API key, and 300 per minute per source IP across keys
- `/api-keys/validate`: 120 per minute per source IP

## Webhook delivery signature

Headers on every delivery: `X-CommuniQueue-Delivery-Id` (stable across retries; use as
the dedupe key), `Idempotency-Key` (same value), `X-CommuniQueue-Timestamp` (Unix
seconds), `X-CommuniQueue-Signature` (`v1=` + lowercase hex HMAC-SHA256).

Signed string: `{timestamp}.{deliveryId}.{rawRequestBody}` as exact UTF-8 bytes. Key: the
workspace signing secret from `/workspaces/{id}/settings/webhooks` (shown once; rotating
it invalidates the old one immediately).

```js
const crypto = require('crypto')

function isValidDelivery(headers, rawBody, signingSecret) {
  const timestamp = headers['x-communiqueue-timestamp']
  const deliveryId = headers['x-communiqueue-delivery-id']
  const signature = headers['x-communiqueue-signature'] // "v1=<hex>"

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false

  const expected = 'v1=' + crypto
    .createHmac('sha256', signingSecret)
    .update(`${timestamp}.${deliveryId}.${rawBody}`, 'utf8')
    .digest('hex')

  const provided = Buffer.from(signature)
  const computed = Buffer.from(expected)
  return provided.length === computed.length && crypto.timingSafeEqual(provided, computed)
}
```

Read the raw body before any JSON middleware parses it; a re-serialised body will not
match.

## Sender verification

- Domain: added under Settings, Senders. Publish two DKIM CNAMEs and one return-path
  CNAME exactly as issued. Rechecked hourly, or press Check DNS. Unfinished requests
  expire after seven days. Add a DMARC record, start at `p=none`.
- Single sender: one address, its owner clicks a verification email, and a postal address
  is required (printed in the footer).
- Allowances: Basic 1 domain and 2 senders, Pro 3 and 5, Business 10 and 10.
- A domain verified by one workspace is shared by every workspace on the account.
- Workspaces sending through their own provider are not checked.

## Template model

Workspace, then project, then container (folders can nest), then template. A template has
versions; the states that matter are Draft, Live, and Previous. The editor autosaves the
draft; Publish makes it live; older versions can be restored as a new draft. Test send runs
the real pipeline to the signed-in user's address and is excluded from reports: from the
editor it renders your draft, from the template page it renders the draft if you have one
and the live version otherwise. Default recipients can be set on the project, container, or template and are
inherited downwards.

Per email version: subject, body, `htmlHead`, from address, from name, email type
(Transactional or Marketing), open tracking, click tracking.

Handlebars helpers registered by the renderer: `formatDate value "format"` (.NET
format string, falls back to the raw value when unparseable), `now "format"` (UTC),
`uppercase`, `lowercase`, block `#ifEquals a b` with `{{else}}`. Built-ins allowed:
`if`, `unless`, `each`, `with`, `lookup`, `log`. Variable names are matched
case-insensitively against tag keys. Each rendered part is limited to 1,048,576 characters.
