Webhooks
Get notified when something happens in your org. Verify the signature, process the event, return 2xx quickly.
When to use webhooks
Use webhooks when:
- You want to react to user events in real time without polling
Synq (
consent.granted,member.added). - You want to mirror Synq state into your own database (e.g. keep a local copy of org membership for fast UI rendering).
- You want long-term retention of audit-loggable events (Synq’s audit log retention is bounded by tier; webhooks let you ship to your own log store).
- You want to trigger backend workflows on identity events
(provision Slack accounts on
member.added, demote permissions onmember.removed).
Don’t use webhooks for synchronous request paths. They are at-least-once and asynchronous; never block a user flow on a webhook.
Subscribe
POST /identity/organizations/<orgSlug>/webhooks
Authorization: Bearer <token with IdentityOrgPermission.WebhooksEdit>
Content-Type: application/json
{
"url": "https://your-app.example.com/synq/webhook",
"events": ["member.added", "oidcClient.created", "consent.granted"]
}events: ["*"] subscribes to everything. Use a specific list once
you know which events you actually need; * is convenient in dev,
loud in prod.
Response includes the HMAC secret:
{
"id": "wh_abc123",
"url": "https://your-app.example.com/synq/webhook",
"events": ["member.added", "oidcClient.created", "consent.granted"],
"secret": "whsec_K7xa…",
"enabled": true,
"createdAt": "2026-06-04T05:00:00Z"
}secret appears once. Store it now.
Verifying delivery
Every POST to your URL includes:
| Header | Value |
|---|---|
Synq-Signature | HMAC-SHA256(rawBody, secret), hex-encoded |
Synq-Event | Event name, e.g. consent.granted |
Synq-Delivery | UUID of this specific delivery — your dedupe key |
Synq-Timestamp | Unix seconds, decimal string |
Content-Type | application/json |
User-Agent | Synq-Webhooks/1.x |
Verify against the raw body bytes, not parsed JSON. JSON serialization is not byte-identical across language implementations; HMAC checks would diverge.
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'
const SECRET = process.env.SYNQ_WEBHOOK_SECRET!
app.post(
'/synq/webhook',
express.raw({ type: '*/*' }), // raw body, NOT json
(req, res) => {
const sig = req.header('Synq-Signature') ?? ''
const expected = createHmac('sha256', SECRET)
.update(req.body) // Buffer
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(sig)
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).send('bad signature')
}
const event = JSON.parse(req.body.toString())
// … process event
res.status(200).send('ok')
},
)In other runtimes:
- Next.js App Router:
await req.text()for the raw body. - Fastify: add
addContentTypeParser('*', { parseAs: 'buffer' }, (req, body, done) => done(null, body)). - Hono:
await c.req.raw.clone().text()(and verify before parsing JSON).
Delivery semantics
- Synq expects a 2xx within 10 seconds.
- Non-2xx (or no response within 10s) is retried with exponential backoff: 5 retries at 1s, 4s, 16s, 64s, 256s.
- After 20 consecutive failures across deliveries, the webhook
auto-disables. Re-enable from the dashboard or via
PATCH /webhooks/<id>with{ "enabled": true }. - Webhooks deliver at-least-once. Idempotent processing is your
job — dedupe via
Synq-Delivery.
Idempotency pattern
async function handleEvent(req, res) {
const deliveryId = req.header('Synq-Delivery')
if (await db.deliveries.exists(deliveryId)) {
return res.status(200).send('seen')
}
await db.deliveries.insert({ id: deliveryId, at: new Date() })
// … process event
res.status(200).send('ok')
}The exists-check + insert should be atomic (upsert into a unique
index on deliveryId is fine).
Event catalog
A non-exhaustive list. The full payload schemas live in Webhook events.
| Event | Fires when |
|---|---|
consent.granted | User grants scopes to an App for the first time, or adds new scopes to an existing grant. |
consent.revoked | User revokes a consent grant (from their dashboard) or app revokes via API. |
member.added | User joins the org. |
member.removed | User leaves the org or is removed. |
member.role.changed | A role is added to or removed from a member. |
oidcClient.created | New App registered. |
oidcClient.updated | App config changed. |
oidcClient.deleted | App disabled or deleted. |
oidcClient.secret.rotated | client_secret rotated. Payload does not contain the new secret. |
socialProvider.upserted | BYO connection added or updated. |
socialProvider.enabled, .disabled | BYO connection toggled. |
tokenGate.created, .updated, .deleted | Token gate CRUD. |
webhook.test | Manual test delivery from POST /webhooks/:id/test. |
High-volume events that are off by default:
| Event | When |
|---|---|
token.issued | Every access token issuance. Useful for SIEM. Subscribe explicitly. |
token.refreshed | Every refresh exchange. |
Testing
POST /identity/organizations/<slug>/webhooks/<id>/test triggers a
synthetic webhook.test event to the URL. The delivery has a real
signature, so use it to validate your verifier path end-to-end
without waiting for a real event.
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
https://synq.id/identity/organizations/acme/webhooks/wh_abc123/testUse it in CI: subscribe a test webhook to your staging URL, fire
/test, assert the receiver responded 200, assert your DB has the
test event.
Rotating the secret
POST /identity/organizations/<slug>/webhooks/<id>/rotate-secret
Authorization: Bearer <token with IdentityOrgPermission.WebhooksEdit>Returns the new secret in a single response. The previous secret becomes invalid immediately.
To rotate without dropped deliveries:
- Generate the new secret.
- Update your verifier to accept either the old or new secret for a transition window (1–2 minutes is enough).
- POST rotation; Synq starts signing with the new secret.
- Once a few minutes have passed, drop the old secret from your verifier.
@synqid/js’s verifyWebhookSignature supports a [old, new] array.
Delivery logs
GET /identity/organizations/<slug>/webhooks/<id>/deliveries
?status=failed&since=2026-06-01T00:00:00Z&limit=50Response:
{
"items": [
{
"id": "del_abc",
"event": "consent.granted",
"url": "https://your-app.example.com/synq/webhook",
"status": "succeeded",
"responseCode": 200,
"responseBodyExcerpt": "ok",
"attemptCount": 1,
"queuedAt": "2026-06-04T05:00:00Z",
"deliveredAt": "2026-06-04T05:00:00.241Z"
}
],
"total": 1
}Status values:
succeeded— Your URL returned 2xx within 10s.failed_timeout— No response within 10s.failed_status— Returned non-2xx.responseCodepopulated.failed_network— DNS, TLS, or transport error.pending— In the retry queue.
Use the logs to debug “my webhook is missing events” — usually the
event is there, with a failed_status and a useful responseBody.
Replay-protection notes
The Synq-Timestamp header gives you a way to reject very-old
deliveries (e.g. a replay attack with a stolen body). The simplest
rule: reject anything older than 5 minutes:
const ts = parseInt(req.header('Synq-Timestamp') ?? '0', 10)
if (Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(401).send('stale')
}This is optional. The HMAC signature already prevents tampering; replay protection is belt-and-suspenders for high-security endpoints.
Mental model summary
- Subscribe per-org, per-URL. One webhook can cover many event
types; use specific
eventsarrays. - Verify against raw body bytes. Timing-safe equality. Never parse JSON before verifying.
- At-least-once. Dedupe by
Synq-Delivery. - Auto-disable after 20 consecutive failures. Re-enable via PATCH or dashboard.
- Rotate by overlap. Accept old + new for a window, then drop the old.
- Test from CI with
POST /webhooks/:id/test.