API: webhooks
Bricqs delivers progression events (badges, tiers, points, streaks, challenges, contests, referrals, rewards) to your backend as HMAC-signed webhooks with automatic retries and manual replay. This guide is the consumer side: verify, dedupe, acknowledge fast, recover.
Key takeaways
Quick read- Subscribe a destination URL to specific event types from the published catalog (/.well-known/events.json). Each destination has its own secret.
- Verify every delivery: X-Bricqs-Signature is t={timestamp},v1={hex}, an HMAC-SHA256 over '{timestamp}.{raw_body}' with the destination secret.
- Dedupe on X-Bricqs-Delivery-Id. It is stable across retries of the same delivery; retries and out-of-order arrival are normal.
- Return 2xx fast (the destination timeout defaults to 10 seconds). Queue real work; never call your CRM inside the handler.
- Retries: up to 3 by default (max 5) at 30s, 5m, 30m. After that the delivery is exhausted; recover with the replay endpoints.
- On replayed deliveries the data object can be redacted to {}. Re-fetch state from GET /api/v1/gamify/state/{participant_id} instead of trusting replayed bodies.
Subscribe
Create a webhook destination
# Standard bq_live_... key carrying the admin (or gamify:admin) scope.
curl -X POST https://api.bricqs.co/api/v1/gamify/webhooks \
-H "X-API-Key: bq_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "crm-progression-sink",
"url": "https://api.your-app.com/bricqs/webhooks",
"events": [
"badge.earned.v1",
"tier.changed.v1",
"points.awarded.v1",
"reward.claimed.v1",
"referral.converted.v1"
],
"retry_count": 3,
"timeout_seconds": 10
}'Destination management requires admin auth (dashboard JWT or an admin-scoped API key). The response includes the generated destination secret; store it server-side and never ship it to a browser. There is no secret rotation endpoint: to rotate, delete the destination and create a new one.
Payload
Envelope and headers
Headers on every delivery:
X-Bricqs-Signature: t={unix_timestamp},v1={hmac_sha256_hex}
X-Bricqs-Event: badge.earned.v1
X-Bricqs-Timestamp: 1789000000
X-Bricqs-Delivery-Id: evt_9f2c... (stable across retries; your dedupe key)
Content-Type: application/json
Body (canonical envelope; the same id also appears on the SSE stream):
{
"id": "evt_9f2c...",
"event": "badge.earned.v1",
"timestamp": "2026-07-19T10:42:00+00:00",
"tenant_id": "…",
"engagement_id": null,
"participant_id": "user_42",
"data": { …event payload per its schema in /.well-known/events.json… }
}Per-event data schemas are published in the event catalog; do not hand-maintain them in your consumer. A test delivery (POST /api/v1/gamify/webhooks/{destination_id}/test) sends X-Bricqs-Event: ping.
Verify
Always check the signature
import crypto from "crypto";
const TOLERANCE_SECONDS = 300;
export async function POST(req: Request) {
const raw = await req.text();
const header = req.headers.get("x-bricqs-signature") ?? "";
// Header format: t={timestamp},v1={hex_signature}
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=", 2) as [string, string])
);
const timestamp = Number(parts.t);
const signature = parts.v1 ?? "";
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
return new Response("Stale or missing timestamp", { status: 401 });
}
// Signed message is "{timestamp}.{raw_body}"
const expected = crypto
.createHmac("sha256", process.env.BRICQS_WEBHOOK_SECRET!)
.update(`${timestamp}.${raw}`)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return new Response("Invalid signature", { status: 401 });
}
const envelope = JSON.parse(raw);
// Dedupe BEFORE processing: X-Bricqs-Delivery-Id === envelope.id
const deliveryId = req.headers.get("x-bricqs-delivery-id") ?? envelope.id;
if (await alreadyProcessed(deliveryId)) return new Response("ok");
// Acknowledge fast; do real work in a queue.
await queueForProcessing(envelope);
return new Response("ok");
}Verify against the RAW body (frameworks that auto-parse JSON break signatures). Parse t and v1 from the header, check timestamp freshness, compare in constant time.
import hmac, hashlib, time
TOLERANCE_SECONDS = 300
def verify(secret: str, header: str, raw_body: bytes) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
ts, signature = int(parts["t"]), parts["v1"]
if abs(time.time() - ts) > TOLERANCE_SECONDS:
return False
msg = f"{ts}.".encode() + raw_body
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)Retries
What happens when you fail
A delivery succeeds only on HTTP 2xx within timeout_seconds (default 10).
Anything else (timeout, 4xx, 5xx) schedules a retry:
attempt 1 immediate
attempt 2 +30 seconds
attempt 3 +5 minutes
attempt 4 +30 minutes
(attempts = retry_count + 1; retry_count default 3, max 5)
After the last attempt the delivery is EXHAUSTED. Nothing re-sends it
automatically; recover with replay:
POST /api/v1/gamify/webhooks/{destination_id}/deliveries/{log_id}/replay
POST /api/v1/gamify/webhooks/{destination_id}/replay (bulk, returns a job)
GET /api/v1/gamify/webhooks/{destination_id}/replay-jobs/{job_id}
GET /api/v1/gamify/webhooks/{destination_id}/logs (delivery history)
Non-guarantees to design around:
- No ordering, within or across participants.
- Replayed/retried deliveries can carry "data": {} (redacted). Re-fetch
GET /api/v1/gamify/state/{participant_id} on replay.
- No secret rotation endpoint: rotate by delete + recreate.Common mistakes
What goes wrong
Verifying the parsed JSON instead of the raw body. Signature fails on every payload.
Read the raw body once, verify against '{timestamp}.{raw_body}', then parse. Opt out of framework auto-parsing for the webhook route.
Doing CRM and email work inside the handler. Timeouts cause retries and duplicate side effects.
Queue the work, return 2xx immediately. The destination timeout defaults to 10 seconds.
Deduping on nothing, or on the event name. Retries re-run your side effects.
Dedupe on X-Bricqs-Delivery-Id (equals envelope id, stable across retries of a delivery). Store processed ids with a TTL.
Trusting the payload body of a replayed delivery.
Replays can carry data: {}. Treat the envelope as a trigger and re-fetch current participant state from the API.
Assuming delivery order and building a state machine on it.
There is no ordering guarantee. Make handlers commutative or order on the envelope timestamp.
Developer FAQ
Common questions when integrating gamification with Bricqs.
Ready to ship?
Wire it up with the Bricqs SDK or API
Headless SDK for React UIs, REST API for any backend. Same engine behind both.
