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.
* wildcard) to receive every event in the scope.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", "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, and hierarchy changes within one Environment, plus the identity events about that Environment's members:identity.status_set, identity.erased, identity.env_membership.added, identity.env_membership.removedassignment.created, assignment.updated, assignment.removed, assignment.bulk_created, assignment.bulk_removed, assignment.bulk_role_changedrole.created, role.updated, role.deleted, role.permissions.updatedpermission.created, permission.updated, permission.deletednode.created, node.updated, node.deleted, node.moved, hierarchy_schema.updatedorganization.created, organization.updated, organization.deleted, organization.member.added, organization.member.role_changed, organization.member.removed
Account events: identity and membership changes that span Environments:identity.created, identity.updated, identity.status_set, identity.erasedidentity.env_membership.added, identity.env_membership.removedaccount_member.added, account_member.removedsession.all_revoked
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.
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:X-Canopy-Event: the event type (e.g. role.created).X-Canopy-Webhook-Id: a per-delivery id (not the subscription id); identical across retries, so use it as an idempotency key.X-Canopy-Timestamp: Unix time (seconds) of the attempt.X-Canopy-Signature: the HMAC signature, in the form v1,<hex>.
Verifying signatures
Verify every request before acting on it. The X-Canopy-Signature header is v1, followed by an HMAC-SHA256, keyed with the subscription's 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.
const crypto = require("node:crypto");
function verify(headers, rawBody, secret) {
const id = headers["x-canopy-webhook-id"];
const ts = headers["x-canopy-timestamp"];
const received = headers["x-canopy-signature"]; // "v1,<hex>"
const expected =
"v1," +
crypto
.createHmac("sha256", secret)
.update(`${id}.${ts}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(received),
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).
Delivery & retries
Deliveries are asynchronous and at-least-once. A delivery succeeds on any 2xx response within a 10-second timeout. On failure Canopy retries up to 5 attempts with backoff 10s → 1m → 5m → 15m → 1h; 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 every attempt (status, response code, and the first 1,000 characters of the response 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. To rotate the signing secret (Environment webhooks), open the webhook and click Rotate secret, or call POST …/webhooks/:id/rotate-secret; the new secret is shown once and the old one stops verifying immediately.
Tell us how we can improve this guide.