Delegated access to Zev products
Delegated access webhooks
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. data.tenantMode says which.
They are signed, retried and delivered exactly like every other ZevAuth webhook. 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:
{
"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. |
App events
delegated_access.app.updated is about your app on one product, not one user:
{
"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.
Applying events safely
- Verify the signature (how).
- Ignore the event if you already processed its
data.eventId. - For connection events, compare
data.gvwith what you stored fordata.grantId. Skip it if it is not newer. - Store the new state and act on it: on
revoked, delete the stored refresh token; onupdated, readeffectiveScopesandtargets.
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.
Updated at, Wednesday, October 21, 2026