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 uses the app's emergency expire button, which force-expires their active personal shares and secure requests; team items are not touched. 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 | The owner closes an active secure request: DELETE /v1/requests/{shortCode} on an active request, or Expire request now in the app. What happens to its submissions depends on which — see below. A request reaching its expired_at on its own fires no event. |
request.deleted | The owner permanently removes an already-expired secure request: DELETE /v1/requests/{shortCode}, or Delete expired request in the app. The event is about the request record, not its submissions — see below. |
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 | You revoke an API key: with Revoke on the API keys card, or by declining its request for account custody. |
api_key.custody_granted | An API key is granted account custody: one of your devices wraps your account key to that API key's custody public key. |
api_key.* and zk.* events are the ones a security team wants. A credential appearing on a production account at 03:00, or being granted account 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 follow what happened to the request
The same two events come from the API and from the app. Each follows what actually happened to the request rather than what was asked for. Neither tells you what happened to its submissions, which depends on where the action was taken:
| Action | Event | Submissions already received |
|---|---|---|
DELETE /v1/requests/{shortCode} on an active request | request.expired | Not touched. Each stays readable until its own expiry. |
DELETE /v1/requests/{shortCode} on an already-expired request | request.deleted | The content of every live submission is destroyed in the same step; metadata audit rows remain. |
| Expire request now in the app | request.expired | The content of every live submission is scrubbed first; metadata audit rows remain. |
| Delete expired request in the app | request.deleted | The content of every live submission is scrubbed first, and the request's page stops listing its submissions. |
Both payloads carry only short_code. Do not read request.deleted as "the submissions are gone", or request.expired as "the submissions are safe". The emergency expire button in the app expires a user's active personal requests without either event; the action is reported by a single user.emergency_expire. 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?, expired_at? |
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 "a key asked for account custody" 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 expiry, as an RFC 3339 timestamp such as 2026-09-03T17:00:00Z. On request.submitted it is the submission's own expiry. When the countdown starts at first view, share.created and request.submitted carry the latest the share or submission can last if nobody opens it: 30 days plus the chosen time after it was created or submitted. The first open can bring the expiry earlier. For a share, share.viewed carries the expiry as it then stands. Opening a submission in the app sends no event, so a submission's earlier expiry is never delivered. |
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 personal shares the batch expired. Secure requests expired by the same action are not counted. |
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: the <keyId> segment of the credential, which identifies the key but is not one of its secret parts. The auth secret and the custody secret are not included. |
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. |
On request.submitted, short_code identifies the submission for the submissions endpoint. It is not a link: no route opens a submission by its short code.
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.
api_key.revoked reports, and with the account key when a
destructive rotation replaces it, which zk.rotated reports with destructive: true.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": "Response to: Onboarding credentials",
"expired_at": "2026-09-10T14:22:11Z"
}
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.