Event reference
There are nineteen event codes. The set is closed: you choose which of them an endpoint receives, and a code outside this list is refused when you save the selection rather than stored and quietly ignored.
Each endpoint has its own selection, and saving a selection replaces the previous one wholesale — the events you tick are the events the endpoint has, not events added to what it had before.
Event codes
| Event | Fires when |
|---|---|
share.created | A share is created, from the app or the API. A submission to a secure request does not fire this — it fires request.submitted instead. |
share.viewed | A recipient successfully opens the share and its content is returned. |
share.expired | A share reaches the end of its life, either because the periodic sweep found it expired or because a read did. |
share.deleted | The owner deletes the share, from the app or with DELETE /v1/shares/{shortCode}. |
share.access_denied | Someone tries to open a share and is refused for a reason CredenShare can attribute — see reason below. |
user.emergency_expire | The owner force-expires everything. One event for the whole batch, not one per share — see below. |
request.created | You create a secure request. The request's field definitions are deliberately not included. |
request.submitted | Someone submits an answer to your secure request. |
request.expired | An active secure request is closed. The first DELETE /v1/requests/{shortCode} fires this. |
request.deleted | A secure request is permanently removed, submissions included. A second DELETE on an already-expired request fires this. |
org.member_added | An invited member accepts the invitation. Not when the invitation is sent. |
org.member_removed | A member is removed from the organization. |
zk.device_linked | A device is linked to your zero-knowledge account. |
zk.device_revoked | A linked device is revoked. |
zk.team_key_granted | An admin grants a team key to a member. |
zk.rotated | The account key itself is rotated. destructive says whether everything already stored was orphaned. |
api_key.created | An API key is minted on the account. |
api_key.revoked | An API key is revoked. |
api_key.custody_granted | An API key is granted the ability to decrypt what the account can. |
api_key.* and zk.* events are the ones a security team wants. A credential appearing on a production account at 03:00, or gaining custody, is the signal worth alerting on. Subscribe to api_key.created, api_key.custody_granted and zk.rotated if you are wiring this into monitoring rather than into a product feature.share.downloaded and share.exploded are not event codes, despite appearing in some older notes. The set above is closed: naming a code outside it is refused when you create the subscription rather than stored and silently never fired.
user.emergency_expire is one event for the batch
Force-expiring an account's shares fires a single event carrying expired_count, not one event per share. That is deliberate: a per-share event would mean a delivery storm at exactly the moment somebody is dealing with an incident, and the individual short codes are not the useful part of that signal.
If you need to know which shares went, reconcile against your own records — the event tells you how many and when.
request.expired and request.deleted are two outcomes of one call
DELETE /v1/requests/{shortCode} behaves differently depending on the request's state, and the event follows what actually happened rather than what was asked for:
| Call | Event |
|---|---|
DELETE on an active request | request.expired |
DELETE on an already-expired request | request.deleted |
Both payloads carry only short_code. If you are watching for destruction rather than closure, request.deleted is the one that means the submissions are gone. See Expire or delete a request.
Who receives an event
Endpoints belong to an account, and an event goes to the endpoints of the account that owns the thing the event is about — not to whoever triggered it. So:
- Share events go to the share's owner, including
share.viewedandshare.access_denied, which are caused by a recipient. request.submittedgoes to the request's owner, not to the person who submitted.org.member_addedandorg.member_removedgo to the organization's owner.zk.team_key_grantedgoes to the admin who granted the key. The member who received it is not notified through webhooks.
The delivery payload
The body is a flat JSON object. event holds the event code; the remaining keys
name the object involved, and are strings apart from the five noted in the field
reference below. There is no wrapper object, and no id or timestamp inside
the body — the delivery id and the event code arrive in the
X-CredenShare-Delivery and X-CredenShare-Event headers.
Optional fields are omitted, not sent as null. Write your handler to treat
a missing key as absent rather than expecting every key on every delivery.
Payloads are metadata only. No delivery ever carries shared content, a link fragment, a key, a passcode or an access token — see Payloads carry metadata only for why.
Fields by event
| Event | Fields besides event |
|---|---|
share.created | short_code, title?, organization_id?, expired_at? |
share.viewed | short_code, title?, organization_id?, expired_at? |
share.expired | short_code, then either title? or expired_on |
share.deleted | short_code |
share.access_denied | short_code, reason |
user.emergency_expire | expired_count |
request.created | short_code, title?, organization_id? |
request.submitted | short_code, secure_request_short_code, title?, organization_id? |
request.expired | short_code |
request.deleted | short_code |
org.member_added | organization_id, email |
org.member_removed | organization_id, user_id |
zk.device_linked | link_code |
zk.device_revoked | envelope_id, kind? |
zk.team_key_granted | organization_id, target_user_id |
zk.rotated | destructive |
api_key.created | key_id, name, scopes, custody_level, custody_level_requested |
api_key.revoked | key_id, reason |
api_key.custody_granted | key_id |
Fields marked ? are present only when they have a value.
api_key.created, alert on custody_level_requested, not custody_level. A monitor watching custody_level for "something asked for the ability to read everything" would never fire.custody_levelis always0on this event, and truthfully so: a key cannot hold a non-zero custody level until a public key has been registered for it, and the client cannot register one until it holds the credential — which does not exist until after this event fires. So a key that asked for full account custody still reports0here.custody_level_requestedis the argument the caller passed, and it is the security-relevant intent.- The actual grant arrives later, as a separate
api_key.custody_grantedevent.
share.expired has two shapes. When the periodic sweep detects the expiry you
get short_code and, if the share had one, title. When a read detects it you
get short_code and expired_on with the value read, and no title. Handle
both: require nothing but short_code.The two organization events also differ from each other: org.member_added
identifies the member by email, while org.member_removed identifies them by
user_id. Read each one on its own terms.
Field reference
Most fields are JSON strings. Five are not, and they are marked below — a handler that coerces everything to a string will mangle expired_count, destructive, scopes, custody_level and custody_level_requested.
| Field | Notes |
|---|---|
event | Always present. One of the nineteen codes above. |
short_code | The short code of the share, submission or secure request the event concerns. |
title | The object's title. Omitted when it has none. |
organization_id | The organization the object belongs to. Omitted for personal objects. |
expired_at | The share's expiry, as an RFC 3339 timestamp such as 2026-09-03T17:00:00Z. Omitted when the share has no expiry. |
expired_on | Only on share.expired, and only from the read path. Always the value read. |
reason | On share.access_denied, one of the five values below. On api_key.revoked, free text describing why. |
secure_request_short_code | Only on request.submitted. The secure request that the submission answers; short_code on the same delivery is the submission itself. |
email | Only on org.member_added. The email address of the member who accepted. |
user_id | Only on org.member_removed. |
link_code | Only on zk.device_linked. The code used for the pairing. |
envelope_id | Only on zk.device_revoked. |
kind | Only on zk.device_revoked. Currently always device — see below. |
target_user_id | Only on zk.team_key_granted. The member who was granted the key. |
expired_count | Only on user.emergency_expire. A number, not a string: how many shares the batch expired. |
destructive | Only on zk.rotated. A boolean. true means everything already stored was orphaned by the rotation. |
key_id | On the three api_key.* events. The key's id — never any part of the credential itself. |
name | Only on api_key.created. The name the key was given so it can be told apart in a list. |
scopes | Only on api_key.created. An array of strings, the scopes the key was minted with. |
custody_level | Only on api_key.created. A number, and always 0 — see the warning above. |
custody_level_requested | Only on api_key.created. A number: the custody level the caller asked for. This is the one to alert on. |
reason is the one field name that means different things on different events. On share.access_denied it is one of five fixed values you can branch on; on api_key.revoked it is free text meant for a human. Branch on event before you interpret reason.Values of kind
kind names what was revoked. Today it is always device: the revocation behind this event
deletes device envelopes only, so no other value can currently reach you. It is sent as an
explicit statement rather than left implied, so a future envelope type does not change the
meaning of a payload that never said which kind it meant.
Two properties worth coding against:
- Treat it as optional. It is read before the revocation, because afterwards the row is gone, and a failed lookup omits the field rather than blocking the revocation. Do not fail closed on its absence.
- Do not switch exhaustively on it. Handle
device, and ignore values you do not recognise rather than erroring.
Values of reason
share.access_denied carries exactly one of these strings:
reason | Meaning |
|---|---|
Passcode does not match | A passcode was supplied and was wrong. |
Passcode required | The share is passcode-protected and none was supplied. |
Login required | The share requires a signed-in recipient. |
You do not have permission to perform this action on this team | The reader is not permitted to act in that team. |
You don't have permission to access this share | The reader is signed in but is not an allowed recipient. |
An unknown short code does not fire share.access_denied. That is
deliberate: a wrong access token and a code that never existed look the same from
the outside, so emitting on it would turn anyone guessing at short codes into a
flood of notifications for you.
Example deliveries
A share created with a title, an expiry, and an owning organization. Values are illustrative:
{
"event": "share.created",
"short_code": "9kQ2vX7mB4",
"title": "Staging database password",
"organization_id": "4d1c9e2a-8b57-4f31-9a6e-2c0f7b8d1e34",
"expired_at": "2026-09-03T17:00:00Z"
}
A refused read of that same share:
{
"event": "share.access_denied",
"short_code": "9kQ2vX7mB4",
"reason": "Passcode does not match"
}
A submission to a secure request. short_code is the submitted share;
secure_request_short_code is the request it answers:
{
"event": "request.submitted",
"short_code": "T4hL8sD1nW",
"secure_request_short_code": "R7bK2mP9xQ",
"title": "Onboarding credentials"
}
Handling unknown codes
The nineteen codes above are the whole set today, and you cannot subscribe to
anything outside it. Still, write your handler so that a code it does not
recognise is logged and acknowledged with a 2xx rather than treated as an
error: a 4xx on an unrecognised event ends that delivery with no retry, and if
the set is ever extended you would rather find out from your logs than from a
gap.