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.
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:
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:
| Header | Value |
|---|---|
QuiC-Signature | t=<unix seconds>,v1=<hex> — HMAC-SHA256 of t + "." + raw body keyed with your signing secret |
QuiC-Event-Id | The event id (evt_…) — dedupe on it |
QuiC-Event-Type | e.g. message.received |
QuiC-Delivery-Id | This delivery (retries keep the same event id) |
QuiC-Delivery-Attempt | Attempt 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.
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);
});
}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
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);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 "", 200Delivery 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#
| Endpoint | Does |
|---|---|
GET /webhooks | List endpoints |
POST /webhooks | Add one (returns the secret once, runs the handshake) |
GET /webhooks/:id | Get one |
PATCH /webhooks/:id | Change events; a new url needs verifyToken and re-verifies |
DELETE /webhooks/:id | Remove |
POST /webhooks/:id/verify | Re-run the handshake |
POST /webhooks/:id/test | Send a webhook.test event |
POST /webhooks/:id/rotate-secret | New secret; the old one signs for 24 h more |
GET /webhook-deliveries | Recent deliveries (filter by status or subscription) |
POST /webhook-deliveries/:id/replay | Deliver again |