Webhooks

Receiving events

QuiC POSTs signed JSON events to an HTTPS endpoint you own: messages from users, delivery and read receipts, consent changes and app status changes.

1. Register an endpoint#

Add an endpoint in the console (Webhooks tab) or with the API. Choose a verify token — any random string of 8-128 printable characters — and the events you want. Up to 5 endpoints per app.

POST /webhooks
curl https://api.quic.chat/platform/v1/webhooks \
  -H "Authorization: Bearer $QUIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/quic/webhook",
    "verifyToken": "a-long-random-string-you-choose",
    "events": ["message.received", "message.status", "consent.updated", "app.status_changed"]
  }'

The response contains the endpoint's signing secret, shown once. Store it; you need it to verify every event.

2. Answer the verification handshake#

Before delivering anything, QuiC proves you control the URL:

Handshake request
GET https://example.com/quic/webhook?quic.mode=subscribe&quic.verify_token=<your token>&quic.challenge=<random>

Check quic.verify_token matches your token, then answer 200 with the quic.challenge value as the plain-text body, within 5 seconds. Until then the endpoint stays pending_verification and receives nothing. Fixed your endpoint? Re-verify from the console or with POST /webhooks/:id/verify and the same token.

3. Verify every signature#

Each delivery is a POST with these headers:

Delivery headers
HeaderValue
QuiC-Signaturet=<unix seconds>,v1=<hex> — HMAC-SHA256 of t + "." + raw body keyed with your signing secret
QuiC-Event-IdThe event id (evt_…) — dedupe on it
QuiC-Event-Typee.g. message.received
QuiC-Delivery-IdThis delivery (retries keep the same event id)
QuiC-Delivery-AttemptAttempt number

Compute the HMAC over the raw bytes you received — parsing and re-serialising the JSON changes them. Compare in constant time, and reject timestamps more than 5 minutes from your clock. These verifiers are tested against the same golden vectors QuiC's signer uses.

verifyQuicSignature.js (Node.js)
import crypto from 'node:crypto';

/**
 * Verify a QuiC webhook. rawBody must be the exact bytes you received
 * (a Buffer or string) — not re-serialised JSON.
 */
export function verifyQuicSignature(rawBody, header, secret, toleranceSec = 300, now = Math.floor(Date.now() / 1000)) {
  if (typeof header !== 'string' || header.length === 0) return false;
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const eq = part.indexOf('=');
    if (eq === -1) continue;
    const key = part.slice(0, eq).trim();
    const value = part.slice(eq + 1).trim();
    if (key === 't') timestamp = Number(value);
    else if (key === 'v1') signatures.push(value);
  }
  if (!Number.isInteger(timestamp) || Math.abs(now - timestamp) > toleranceSec) return false;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest();
  // During a secret rotation QuiC sends two v1 values for 24 hours.
  return signatures.some((sig) => {
    const given = Buffer.from(sig, 'hex');
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}
verify.py (Python 3.10+)
import hashlib
import hmac
import time


def verify_quic_signature(raw_body: bytes, header: str, secret: str,
                          tolerance: int = 300, now: int | None = None) -> bool:
    """Verify a QuiC webhook. raw_body must be the exact bytes you received."""
    if not header:
        return False
    timestamp, signatures = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t" and value.isdigit():
            timestamp = int(value)
        elif key == "v1":
            signatures.append(value)
    now = int(time.time()) if now is None else now
    if timestamp is None or abs(now - timestamp) > tolerance:
        return False
    expected = hmac.new(secret.encode("utf-8"),
                        f"{timestamp}.".encode("utf-8") + raw_body,
                        hashlib.sha256).hexdigest()
    # During a secret rotation QuiC sends two v1 values for 24 hours.
    return any(hmac.compare_digest(expected, sig) for sig in signatures)

A complete receiver

server.js (Express)
import express from 'express';
import { verifyQuicSignature } from './verifyQuicSignature.js';

const app = express();
const seen = new Set(); // use a database or Redis in production

// 1. Verification handshake: echo the challenge if the token matches.
app.get('/quic/webhook', (req, res) => {
  if (req.query['quic.mode'] === 'subscribe' && req.query['quic.verify_token'] === process.env.QUIC_VERIFY_TOKEN) {
    return res.status(200).type('text/plain').send(req.query['quic.challenge']);
  }
  return res.sendStatus(403);
});

// 2. Events: verify the signature on the RAW body, dedupe on event id, answer fast.
app.post('/quic/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyQuicSignature(req.body, req.get('QuiC-Signature'), process.env.QUIC_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString('utf8'));
  if (seen.has(event.id)) return res.sendStatus(200);
  seen.add(event.id);
  res.sendStatus(200); // acknowledge within 10 seconds, then do the work
  handleEvent(event);
});

function handleEvent(event) {
  switch (event.type) {
    case 'message.received':
      console.log(`${event.data.from} says: ${event.data.text}`);
      break;
    case 'consent.updated':
      // opted_out or blocked: stop messaging this appUserId immediately.
      break;
  }
}

app.listen(3000);
app.py (Flask)
import json
import os

from flask import Flask, abort, request

from verify import verify_quic_signature

app = Flask(__name__)


@app.get("/quic/webhook")
def subscribe():
    if (request.args.get("quic.mode") == "subscribe"
            and request.args.get("quic.verify_token") == os.environ["QUIC_VERIFY_TOKEN"]):
        return request.args.get("quic.challenge", ""), 200, {"Content-Type": "text/plain"}
    abort(403)


@app.post("/quic/webhook")
def events():
    raw = request.get_data()  # the exact bytes, before any JSON parsing
    if not verify_quic_signature(raw, request.headers.get("QuiC-Signature", ""),
                                 os.environ["QUIC_WEBHOOK_SECRET"]):
        abort(401)
    event = json.loads(raw)
    # Dedupe on event["id"], then handle event["type"].
    return "", 200

Delivery and retries#

  • Answer with any 2xx within 10 seconds. Do slow work after you answer. Redirects are not followed.
  • On a timeout, a network error, 408, 429 or 5xx QuiC retries with exponential backoff (about 10 s, doubling, capped at 1 hour) for up to 24 hours, then marks the delivery failed. Other 4xx answers are not retried.
  • Events can arrive more than once and out of order. Dedupe on the event id; use timestamps to order.
  • If an endpoint keeps failing for 3 days it is disabled and the app's creator is emailed. Fix it and re-verify to turn it back on.
  • Replay any delivered or failed delivery from the console (Logs) or with POST /webhook-deliveries/:id/replay — same event id. Payloads are kept for 7 days.

Test events#

Send test event in the console (or POST /webhooks/:id/test) delivers a signed webhook.test event to that endpoint only.

Rotating the secret#

POST /webhooks/:id/rotate-secret returns a new secret. For the next 24 hours each delivery carries two v1 signatures — one per secret — so deploy the new secret at your own pace. The verifiers above accept either.

Endpoint rules#

HTTPS on port 443 with a public DNS name. Addresses in private, loopback, link-local and cloud-metadata ranges are refused when you register and on every delivery.

Subscription API#

Webhook subscription endpoints
EndpointDoes
GET /webhooksList endpoints
POST /webhooksAdd one (returns the secret once, runs the handshake)
GET /webhooks/:idGet one
PATCH /webhooks/:idChange events; a new url needs verifyToken and re-verifies
DELETE /webhooks/:idRemove
POST /webhooks/:id/verifyRe-run the handshake
POST /webhooks/:id/testSend a webhook.test event
POST /webhooks/:id/rotate-secretNew secret; the old one signs for 24 h more
GET /webhook-deliveriesRecent deliveries (filter by status or subscription)
POST /webhook-deliveries/:id/replayDeliver again