BricqsBricqs

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.

Last updatedJuly 2026

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

POST /api/v1/gamify/webhooks·bash
# 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

text
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

app/api/bricqs/webhooks/route.ts (Next.js)·ts
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.

consumer.py (FastAPI)·python
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

text
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

01Mistake

Verifying the parsed JSON instead of the raw body. Signature fails on every payload.

Fix

Read the raw body once, verify against '{timestamp}.{raw_body}', then parse. Opt out of framework auto-parsing for the webhook route.

02Mistake

Doing CRM and email work inside the handler. Timeouts cause retries and duplicate side effects.

Fix

Queue the work, return 2xx immediately. The destination timeout defaults to 10 seconds.

03Mistake

Deduping on nothing, or on the event name. Retries re-run your side effects.

Fix

Dedupe on X-Bricqs-Delivery-Id (equals envelope id, stable across retries of a delivery). Store processed ids with a TTL.

04Mistake

Trusting the payload body of a replayed delivery.

Fix

Replays can carry data: {}. Treat the envelope as a trigger and re-fetch current participant state from the API.

05Mistake

Assuming delivery order and building a state machine on it.

Fix

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.

1 brief to align the room2 mechanics max in version one
What happens next
01
Pick the mechanic
Choose the smallest working system for the brief.
02
Launch without rebuilds
Configure rules and rewards in one place.