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.

Open Integrations → Webhooks (Environment scope) or the Account-level Webhooks page (Account scope) and click Add Webhook.Enter the endpoint URL Canopy will POST to.Pick the event types to subscribe to, or choose all events (the * wildcard) to receive every event in the scope.Optionally add a description to identify the subscription later.Click Create. A dialog shows the signing secret. Copy it into your secrets manager before closing; it's shown only once.

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",
    "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.removed
  • assignment.created, assignment.updated, assignment.removed, assignment.bulk_created, assignment.bulk_removed, assignment.bulk_role_changed
  • role.created, role.updated, role.deleted, role.permissions.updated
  • permission.created, permission.updated, permission.deleted
  • node.created, node.updated, node.deleted, node.moved, hierarchy_schema.updated
  • organization.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.erased
  • identity.env_membership.added, identity.env_membership.removed
  • account_member.added, account_member.removed
  • session.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.

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

Tell us how we can improve this guide.