Webhook event catalog
Every event Synq can deliver, with the payload schema. Subscribe to
any subset via the events array on POST /webhooks.
Every delivery has this envelope:
interface WebhookEnvelope {
id: string // delivery id — matches Synq-Delivery header
type: string // event name — matches Synq-Event header
timestamp: string // ISO 8601
org: string // org slug
actorUserId: string | null
data: object // event-specific shape (below)
}Consent events
consent.granted
{
"user": "user_abc",
"app": "client_id_xyz",
"scopes": ["openid", "profile", "email"]
}Fires when:
- A user grants scopes to an App for the first time.
- A user grants additional scopes to an existing grant (only the
newly-granted scopes appear in
scopes).
consent.revoked
{
"user": "user_abc",
"app": "client_id_xyz",
"scopes": ["openid", "profile", "email"]
}Fires when a user revokes a grant from their dashboard or an app
revokes via DELETE /users/:userId/consent-grants/:appId.
Member events
member.added
{
"user": "user_abc",
"roles": ["role_owner"]
}User joined the org (typically via invite acceptance).
member.removed
{
"user": "user_abc",
"removedRoles": ["role_admin", "role_member"]
}User left or was removed.
member.role.changed
{
"user": "user_abc",
"added": ["role_admin"],
"removed": ["role_member"]
}Member’s role assignments changed. added and removed are
disjoint.
OIDC client events
oidcClient.created
{
"clientId": "marketplace-web",
"brand": "marketplace",
"name": "Marketplace Web",
"type": "web",
"isConfidential": true,
"redirectUris": ["https://marketplace.com/api/oauth/callback"],
"allowedGrantTypes": ["authorization_code", "refresh_token"],
"allowedScopes": ["openid", "profile", "email"]
}oidcClient.updated
{
"clientId": "marketplace-web",
"before": { "name": "Marketplace Web", "redirectUris": ["…"] },
"after": { "name": "Marketplace Web Prod", "redirectUris": ["…", "…"] }
}before and after contain only the fields that actually changed.
oidcClient.deleted
{
"clientId": "marketplace-web",
"brand": "marketplace"
}oidcClient.secret.rotated
{
"clientId": "marketplace-web"
}Does not contain the new secret. Rotate via API to receive it in the response.
Provider (BYO) events
socialProvider.upserted
{
"brand": "marketplace",
"type": "google",
"enabled": true
}A connection was created or its config was updated. The config itself is not in the payload (it’s encrypted at rest and never emitted).
socialProvider.enabled
{
"brand": "marketplace",
"type": "google"
}socialProvider.disabled
{
"brand": "marketplace",
"type": "google"
}Token gate events
tokenGate.created
{
"gateId": "gate_abc",
"name": "Holder access",
"mintAddress": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB",
"minHoldings": "1000000",
"enabled": true
}tokenGate.updated
{
"gateId": "gate_abc",
"before": { "minHoldings": "1000000" },
"after": { "minHoldings": "5000000" }
}tokenGate.deleted
{
"gateId": "gate_abc"
}System events
webhook.test
{
"test": true
}Fired by POST /webhooks/:id/test. The delivery still includes a
real signature, so use it to validate your verification path
end-to-end.
High-volume events (off by default)
Subscribe explicitly via the events array; don’t use * if you
don’t want them.
token.issued
{
"user": "user_abc",
"app": "marketplace-web",
"scopes": ["openid", "profile"],
"grant": "authorization_code"
}Fires on every successful token issuance.
token.refreshed
{
"user": "user_abc",
"app": "marketplace-web",
"scopes": ["openid", "profile"]
}Fires on every refresh-token exchange.
Never includes the access token or refresh token itself.
Subscribing to a subset
await fetch('https://synq.id/identity/organizations/acme/webhooks', {
method: 'POST',
headers: { Authorization: `Bearer ${tok}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://example.com/webhook',
events: [
'consent.granted',
'consent.revoked',
'member.added',
'member.removed',
'oidcClient.created',
'oidcClient.deleted',
],
}),
})Receiving + dispatching
A typical receiver:
import { verifyWebhookSignature } from '@synqid/js'
app.post('/synq/webhook', express.raw({ type: '*/*' }), async (req, res) => {
const sig = req.header('Synq-Signature')
if (!verifyWebhookSignature(req.body, sig, SECRET)) {
return res.status(401).end()
}
const event = JSON.parse(req.body.toString())
switch (event.type) {
case 'member.added':
await provisionUser(event.data.user)
break
case 'consent.granted':
await trackConsentMetrics(event.data)
break
case 'oidcClient.deleted':
await invalidateAppCaches(event.data.clientId)
break
case 'webhook.test':
break
// …
default:
console.warn('unknown event type', event.type)
}
res.status(200).end()
})Mental model summary
- Every event has the same envelope (
id,type,timestamp,org,actorUserId,data). datashape varies per event type but is stable.- High-volume events (
token.*) are off by default; opt in explicitly. - For mutating events,
before+aftergive you a diff. - Webhook signatures cover the entire payload; verify before parsing JSON.