Webhooks

Outbound webhook delivery

A Webhook template sends its rendered payload to a destination you supply on the send call itself, not to a URL fixed on the template. Each delivery is signed so you can verify it actually came from CommuniQueue.

Set it up

Webhook delivery is gated by the workspace's webhookDelivery entitlement. A workspace Owner or Admin generates the signing secret from Settings at /workspaces/{workspaceId}/settings/webhooks. The secret is shown once, at rotation - store it the same way you would an API key. Rotating it invalidates the previous secret immediately; there is no overlap period where both work. Go to your workspaces to open one and generate a secret there.

Send it

Create and publish a Webhook template, then call the normal send endpoint with a destination in webhookOptions:

curl -X POST https://api.communiqueue.com/api/v1/notifications/send \
  -H "X-Api-Key: cq_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "tenantId": "<your workspace id>",
    "templateId": "<your webhook template id>",
    "tags": [{ "key": "accountId", "value": "account-123" }],
    "webhookOptions": {
      "urlOverride": "https://hooks.yourapp.example/communiqueue",
      "headers": { "X-Account": "account-123" }
    }
  }'

Destination and header rules

  • The destination must be an absolute https:// URL on the standard port 443 - no other scheme or port.
  • Redirects are never followed, and every DNS answer for the host must be a public address. Loopback, private, link-local, and other non-routable ranges are rejected, for both IPv4 and IPv6.
  • You may attach up to 16 custom headers: 64 characters per name, 1,024 characters per value, and 4,096 bytes total across all of them.
  • Host, Authorization, Cookie, and any header name that looks like it carries a credential, token, or secret are rejected - use the signature below for authentication instead.

Verify the signature

Every delivery carries four headers:

  • X-CommuniQueue-Delivery-Id - stable across retries of the same attempt.
  • Idempotency-Key - the same value as the delivery id, for receivers that dedupe on that header by convention.
  • X-CommuniQueue-Timestamp - Unix seconds when the request was signed.
  • X-CommuniQueue-Signature - v1= followed by a lowercase hex HMAC-SHA256.

Compute the expected signature over the exact UTF-8 bytes of:

{timestamp}.{deliveryId}.{requestBody}

Use your signing secret as the HMAC key, compare with a constant-time comparison rather than ===, reject timestamps outside a short replay window, and treat the delivery id as a dedupe key so a retried request isn't processed twice. Verify against the raw request bytes - don't parse and re-serialize the body first, or the bytes you sign over will drift from the bytes CommuniQueue signed.

Worked example - Node.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>"

  // Reject anything outside a short window - this is what stops a captured
  // request from being replayed later.
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (ageSeconds > 300) return false

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

  const provided = Buffer.from(signature)
  const computed = Buffer.from(expected)

  // Constant-time comparison - a plain === leaks timing information an
  // attacker can use to forge a signature one byte at a time.
  return provided.length === computed.length && crypto.timingSafeEqual(provided, computed)
}

Delivery and retries

  • Any 2xx response counts as success.
  • Network failures, timeouts, 408, 429, and 5xx are retried, up to the notification job's maximum attempts.
  • Any other 3xx or 4xx response is treated as permanent and is not retried.
  • A Retry-After response header is honored, with jitter added and the wait capped at 15 minutes regardless of what the header requests.