Skip to Content
ConceptsWebhooks

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 on member.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:

HeaderValue
Synq-SignatureHMAC-SHA256(rawBody, secret), hex-encoded
Synq-EventEvent name, e.g. consent.granted
Synq-DeliveryUUID of this specific delivery — your dedupe key
Synq-TimestampUnix seconds, decimal string
Content-Typeapplication/json
User-AgentSynq-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.

EventFires when
consent.grantedUser grants scopes to an App for the first time, or adds new scopes to an existing grant.
consent.revokedUser revokes a consent grant (from their dashboard) or app revokes via API.
member.addedUser joins the org.
member.removedUser leaves the org or is removed.
member.role.changedA role is added to or removed from a member.
oidcClient.createdNew App registered.
oidcClient.updatedApp config changed.
oidcClient.deletedApp disabled or deleted.
oidcClient.secret.rotatedclient_secret rotated. Payload does not contain the new secret.
socialProvider.upsertedBYO connection added or updated.
socialProvider.enabled, .disabledBYO connection toggled.
tokenGate.created, .updated, .deletedToken gate CRUD.
webhook.testManual test delivery from POST /webhooks/:id/test.

High-volume events that are off by default:

EventWhen
token.issuedEvery access token issuance. Useful for SIEM. Subscribe explicitly.
token.refreshedEvery 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/test

Use 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:

  1. Generate the new secret.
  2. Update your verifier to accept either the old or new secret for a transition window (1–2 minutes is enough).
  3. POST rotation; Synq starts signing with the new secret.
  4. 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=50

Response:

{ "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. responseCode populated.
  • 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 events arrays.
  • 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.
Last updated on