---
title: Delegated access webhooks
description: "Events we send when a user's connection to your app changes, what each payload carries, and how to apply them safely."
---

When something changes on a connection, ZevID tells us and we tell you. The
events arrive at the normal webhook endpoints of the environment the
connection belongs to: live for real users, test for your team's
[test connections](/delegated-access/testing/). `data.tenantMode` says which.

They are signed, retried and delivered exactly like every other ZevAuth
webhook. [Webhooks](/api/webhooks) covers verifying the signature, retries and
responding. Subscribe to the events below in the console under
**Webhooks**, or leave the endpoint on "all events".

## Events

| Event | When |
| --- | --- |
| `delegated_access.connection.created` | A user connected your app to a Zev product, or reconnected it. |
| `delegated_access.connection.updated` | A connection changed: targets, permissions, a suspension, a pause, or a high-risk permission confirmed or ended. Read `changes`. |
| `delegated_access.connection.revoked` | A connection was removed, by the user, by you, by the product or by ZevID. Stop using its tokens. |
| `delegated_access.app.updated` | Your app was suspended or restored on a product, or a permission was paused or unpaused for your whole app. |
| `delegated_access.security.refresh_reuse` | A refresh token of one of your connections was used twice. Its refresh tokens are revoked and the user must reconnect. |

## Connection events

`created`, `updated` and `revoked` carry the connection as it is now:

```json
{
  "type": "delegated_access.connection.updated",
  "createdAt": "2026-11-09T11:20:13.004Z",
  "environment": "env_live_...",
  "data": {
    "eventId": "8a0c2f5e-1b7d-4c3a-9f21-6d4e5f6a7b80",
    "product": "zevcloud",
    "tenantMode": "live",
    "changes": ["high_risk_expired"],
    "reason": "high_risk_expired",
    "by": "zevid",
    "occurredAt": "2026-11-09T11:20:12.022Z",
    "grantId": "6d1d2c3b-4a5f-4e6d-8c7b-9a0b1c2d3e4f",
    "accountId": "3b9f8e7d-6c5b-4a39-8271-605f4e3d2c1b",
    "gv": 5,
    "status": "active",
    "scopes": ["zevcloud.deploy", "zevcloud.services.delete"],
    "effectiveScopes": ["zevcloud.deploy"],
    "highRisk": [
      { "scope": "zevcloud.services.delete", "confirmedAt": "2026-10-10T11:20:12.000Z", "expiresAt": "2026-11-09T11:20:12.000Z" }
    ],
    "highRiskExpiresAt": null,
    "pausedScopes": [],
    "targets": [{ "id": "team_42", "name": "Acme Prod" }],
    "expiresAt": null
  }
}
```

| Field | Meaning |
| --- | --- |
| `eventId` | Stable across retries and across our deliveries of the same change. De-duplicate on it. |
| `product` | The Zev product, e.g. `zevcloud`. |
| `tenantMode` | `live`, or `test` for a test connection. On every delegated-access event. |
| `changes` | What changed. See the list below. |
| `reason` | The main change, from the same list. |
| `by` | Who caused it: `user`, `partner` (you), `product` or `zevid`. |
| `occurredAt` | When it happened at ZevID. |
| `grantId` | The connection. The same `grant_id` your token response carries. |
| `accountId` | The user's ZevID account id. |
| `gv` | The connection's version. Goes up on every change. |
| `status` | `active`, `suspended` or `revoked`. |
| `scopes` | What the user granted. |
| `effectiveScopes` | What you may use right now: granted, approved, not paused, high-risk only while confirmed. Empty unless `status` is `active`. |
| `highRisk` | Every high-risk permission on the connection. An `expiresAt` in the past means it ended. |
| `highRiskExpiresAt` | Earliest end of an active high-risk permission, epoch seconds, or `null`. |
| `pausedScopes` | `[{ scope, by, level }]`. `by` is `zevid` or `product`; `level` is `grant` (this connection) or `approval` (your whole app). |
| `targets` | What the user picked, `[{ id, name }]`. |
| `expiresAt` | Test connections only: when it ends on its own. `null` for live. |

The payload never carries an email, a person's name or a free-text reason.

### `changes`

| Value | Meaning |
| --- | --- |
| `connected` | First connection. |
| `reconnected` | The user connected again after a revoke, or a fresh authorize replaced the old token chain. |
| `scopes_added` / `scopes_removed` | Permissions changed. |
| `targets_added` / `targets_removed` / `targets_renamed` | The picked targets changed. |
| `suspended` / `unsuspended` | The connection was suspended or restored. |
| `revoked` | The connection was removed. `reason` says why, for example `revoked` (the user or you), `test_grant_expired` (a test connection reached its end) or `test_member_removed` (that person left your team). |
| `high_risk_confirmed` | A high-risk permission was granted. |
| `high_risk_reconfirmed` | The user confirmed high-risk permissions again. |
| `high_risk_expired` | A high-risk window ended. The connection is not revoked. |
| `scope_suspended` / `scope_unsuspended` | A permission was paused or unpaused. See [Paused permissions](/delegated-access/paused-permissions). |

## App events

`delegated_access.app.updated` is about your app on one product, not one user:

```json
{
  "eventId": "…",
  "product": "zevcloud",
  "changes": ["scope_suspended"],
  "reason": "scope_suspended",
  "by": "product",
  "occurredAt": "…",
  "status": "active",
  "approvedScopes": ["zevcloud.deploy", "zevcloud.services.read"],
  "pausedScopes": [{ "scope": "zevcloud.services.read", "by": "product" }],
  "scope": "zevcloud.services.read"
}
```

`status` is `active` or `suspended`. `scope` names the permission a pause is
about, or is `null`. `reason` can also be `app_suspended` or `app_unsuspended`
when ZevID suspended your app on every product at once.

## Refresh reuse

`delegated_access.security.refresh_reuse` carries `grantId`, `accountId`,
`gv` and `status`, with `reason: "refresh_reuse"`. Someone presented a
refresh token that was already used. That is either a stolen token or two
refreshes running at the same time. All refresh tokens of the connection are
revoked; the connection itself stays, and the user reconnects with one click.
Check how you store refresh tokens and that refreshes are serialised; see
[Refresh tokens](/delegated-access/refresh-tokens).

## Applying events safely

> **Warning:**
> Events arrive at least once and in no guaranteed order. Apply a connection
> event only when its `gv` is greater than the `gv` you hold for that
> `grantId`. Otherwise a late retry can turn a revoked connection back into an
> active one in your database.

1. Verify the signature ([how](/api/webhooks)).
2. Ignore the event if you already processed its `data.eventId`.
3. For connection events, compare `data.gv` with what you stored for
   `data.grantId`. Skip it if it is not newer.
4. Store the new state and act on it: on `revoked`, delete the stored refresh
   token; on `updated`, read `effectiveScopes` and `targets`.

## If you missed events

If your endpoint was down beyond our retries, you do not need to replay
anything. The next token refresh for a connection returns its current
`scope`, `targets` and `gv`. A connection that no longer exists fails refresh
with `invalid_grant`.
