Webhooks
Subscribe to events in your environment and receive HTTPS callbacks the moment they happen.
Overview
A webhook tells Canopy to POST a JSON callback to your endpoint the moment something happens, so your systems react to changes without polling. Subscriptions come in two scopes. Environment webhooks fire on env-scoped changes (roles, permissions, assignments, hierarchy) and live inside a single Environment, so a development webhook never sees production events. Account webhooks fire on account-tier events that span Environments (identity lifecycle, membership, session revocation). Each subscription gets its own HMAC signing secret, every delivery is signed and retried, and you can inspect every attempt.
Add from the Console
Adding a webhook from the Developer Console is the fastest path. Environment events are subscribed from the Environment's Integrations page; Account events, which span Environments, from the Account's own Webhooks page. Both open the same wizard.
- Application
- Developer Console
- Path
- Access ControlIntegrationsWebhooks
- Opens with
env_webhooks.viewRead the Environment's webhook subscriptions and their deliveries.
- Context
- Select an Application and an Environment in the switcher first; the page belongs to that Environment.
- Application
- Developer Console
- Path
- TenantWebhooks
- Opens with
account_webhooks.viewRead the Account's webhook subscriptions and their deliveries.
- Context
- Account-wide. No Application or Environment selection applies.
* wildcard), which disables the individual checkboxes. Next enables once a URL and at least one event are set.If you don't see this
Each control on the Webhooks pages exists only for someone who may use it. When something described above is missing, one of these is why:
Add via the API
The same operation is available on the portal API. The endpoint below is env-scoped; account webhooks use the base /portal/v1/accounts/:accountSlug/webhooks. Authentication uses the portal Bearer JWT. The API-key surface, POST /api/v1/webhooks, creates environment-scoped subscriptions, which also receive the identity events about that Environment's members; the account-only events (identity created or updated, account members, sessions) need an account-scoped subscription from the Console or the portal endpoint.
POST /portal/v1/accounts/:accountSlug/applications/:appSlug/environments/:envSlug/webhooksRequest
curl -X POST https://auth.canopy-io.com/portal/v1/accounts/acme/applications/web/environments/production/webhooks \ -H "Authorization: Bearer $PORTAL_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://app.example.com/webhooks", "event_types": ["role.created", "role.deleted"] }'
Response
{ "data": { "id": "6f1c9d20-…", "scope": "environment", "url": "https://app.example.com/webhooks", "event_types": ["role.created", "role.deleted"], "description": null, "is_active": true, "created_at": "2026-06-05T12:00:00.000Z", "previous_secret_expires_at": null, "secret": "e3b0c44298fc1c…" } }
The secret in the response is the only time it's returned in plaintext: it's never included on a list or fetch. The data envelope is the standard shape for every /portal/v1 endpoint that returns a single resource.
Event types
A subscription listens for one or more event types from its scope's catalog: an Environment subscription can only pick Environment events, an Account subscription only Account events.
Environment events: role, permission, assignment, hierarchy, organization and directory-sync changes within one Environment, plus the identity events about that Environment's members:
Account events: identity and membership changes that span Environments:
Subscribe with ["*"] to receive every event in the scope, including ones added in the future. The * wildcard can't be combined with specific event types.
Payload & headers
Each delivery is an HTTP POST with Content-Type: application/json. The body has two fields: event (the event type) and data (the event payload). data is the same envelope every event shares: the scope it belongs to (account_id, application_id, environment_id, null for tiers the event does not have), who acted (actor_id, actor_type), what it touched (resource_type, resource_id), the event's own metadata object, and a timestamp. Event-specific detail lives in metadata; the event types page notes the fields worth reading per event. metadata is always an object. Each event's exact payload, including the fields its metadata carries and which are always present, is published in the API reference as a schema of its own; GET /api/v1/webhooks/event-types names it for every event in payload_schema, beside the resource_types a delivery can carry.
POST https://app.example.com/webhooks
Content-Type: application/json
X-Canopy-Event: organization.member.added
X-Canopy-Webhook-Id: 9b3e1f70-…
X-Canopy-Timestamp: 1730822400
X-Canopy-Signature: v1,9a2f0b…
{
"event": "organization.member.added",
"data": {
"account_id": "…",
"application_id": "…",
"environment_id": "…",
"actor_id": "…",
"actor_type": "identity",
"resource_type": "organization",
"resource_id": "…",
"metadata": {
"identity_id": "…",
"role_id": "…",
"connection_id": "…",
"source": "sso_jit"
},
"timestamp": "2026-09-05T14:02:11.000Z"
}
}Every delivery carries four headers:
Verifying signatures
Verify every request before acting on it. Each value in the X-Canopy-Signature header is v1, followed by an HMAC-SHA256, keyed with a signing secret, over the string {webhook_id}.{timestamp}.{raw_body}: the X-Canopy-Webhook-Id, then the X-Canopy-Timestamp, then the exact request body, joined by dots. The header normally holds one value. For 24 hours after you rotate the secret it holds two, separated by a space, the new secret's first, so a receiver still configured with the old secret keeps verifying while you roll the new one out. Split the header on whitespace and accept the delivery when any v1, value matches the secret you hold.
const crypto = require("node:crypto");
function verify(headers, rawBody, secret) {
const id = headers["x-canopy-webhook-id"];
const ts = headers["x-canopy-timestamp"];
const expected = crypto
.createHmac("sha256", secret)
.update(`${id}.${ts}.${rawBody}`)
.digest("hex");
// "v1,<hex>", or "v1,<hex> v1,<hex>" during a rotation window.
return headers["x-canopy-signature"]
.split(/\s+/)
.filter((value) => value.startsWith("v1,"))
.map((value) => value.slice("v1,".length))
.some(
(actual) =>
actual.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected)),
);
}Sign over the raw bytes you received: re-serializing the parsed JSON can reorder keys or change spacing and break the match. Always compare with a constant-time check (crypto.timingSafeEqual), and compare each value on its own rather than the whole header, or the second value of a rotation window will never match.
Delivery & retries
Deliveries are asynchronous. Once queued, a delivery is at-least-once; queuing happens just after the event is recorded and is not retried, so a failure at that moment loses the delivery, while the audit log keeps the event. A delivery succeeds on any 2xx response within a 10-second timeout. On failure Canopy tries again, up to 5 attempts in all, waiting 10s → 1m → 5m → 15m between them; after the final attempt the delivery is marked failed. Because X-Canopy-Webhook-Id is stable across retries, use it to de-duplicate on your end. Inspect each delivery (its status and attempt count, and for its latest attempt the response code and, after a failure, the first 1,000 characters of the body) via GET …/webhooks/:id/deliveries.
Managing webhooks
List, fetch, edit, and delete subscriptions from the Console or the portal API. PATCH can change the url, event_types, description, and is_active flag: toggle is_active to pause deliveries without losing the configuration. Events raised while it is inactive are not delivered later: reactivating resumes delivery from the next event, and the audit log is where to catch up. To rotate the signing secret, open the webhook and click Rotate secret, or call POST …/webhooks/:id/rotate-secret; the new secret is shown once. The replaced secret keeps verifying for 24 hours: deliveries carry both signatures until then, and previous_secret_expires_at, in the rotate response and on the subscription, says when it stops. Update your endpoint inside that window. Rotating again inside the window replaces the windowed secret rather than keeping a third. For a secret that has leaked, tick This secret was compromised. Stop it now. in the Console or send { "expire_previous": "now" }, and the old secret stops with the rotation.
Tell us how we can improve this guide.