1. Docs
  2. Webhooks

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.view Read 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.view Read the Account's webhook subscriptions and their deliveries.
Context
Account-wide. No Application or Environment selection applies.
In the Developer Console, open Access Control → Integrations → Webhooks for Environment events, or Tenant → Webhooks for Account events, and click Add Webhook. The Add Webhook wizard opens on its Details step.Enter the Endpoint URL Canopy will POST to and an optional description that identifies the subscription later.Under Events, tick the event types to subscribe to, or tick Subscribe to all events (the * wildcard), which disables the individual checkboxes. Next enables once a URL and at least one event are set.Click Next. The Review step lists the URL, description, and the selected events as badges.Click Add Webhook. The one-time reveal shows the signing secret. Copy it into your secrets manager, then click Done; it is shown only once. The subscription is now listed.
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:

Webhooks is not under Integrations. Either no Application and Environment is selected yet, or your administrator role does not carry env_webhooks.view. The Account page under Tenant needs account_webhooks.view instead.The list shows but there is no Add Webhook, and the drawer offers no Edit, Deactivate, Rotate secret, or Delete. You hold the view capability for that tier but not its manage capability: env_webhooks.manage or account_webhooks.manage.An event you expect is not in the picker. Environment and Account subscriptions offer different registries: identity lifecycle and membership events are Account events, role and permission events are Environment events.The subscription you added is not listed. Environment subscriptions belong to one Environment. Check the Environment in the switcher, or look on the Account page if it was an Account event.

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/webhooks

Request

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:

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.deleted_all, organization.policy.updated, organization.sso_connection.bound, organization.sso_connection.unbound, organization.member.added, organization.member.role_changed, organization.member.removedscim.user.provisioned, scim.user.updated, scim.user.deprovisioned, scim.user.reactivated, scim.group.created, scim.group.updated, scim.group.deleted, scim.group.member_added, scim.group.member_removed, scim.group.mapped, scim.group.unmapped

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

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>. For 24 hours after a secret rotation it carries two space-separated values, the new secret's first.

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.

Environment
API version
v1.0
On this page Was this page helpful?

Tell us how we can improve this guide.