Authentication
Every request to the CredenShare API carries an API key in the Authorization header. There is no session, no cookie and no shared gateway key on this surface — the API key is the only credential, and it is checked on every single request.
The credential
A CredenShare API credential has a fixed prefix and three dot-separated parts:
crs_sk_live_<keyId>.<authSecret>.<custodySecret>
│ │ └─ custody secret: never sent on a request
│ └─ auth secret: sent as the bearer credential
└─ key id: identifies the key
You send the prefix, the key id and the auth secret — the first two parts, nothing more:
curl https://api.credenshare.io/v1/shares \
-H "Authorization: Bearer crs_sk_live_<keyId>.<authSecret>"
The three components are 12, 32 and 32 random bytes, each base64url-encoded without padding. So the value you put in the header is 72 characters long, and the full credential you were shown at creation is 116 characters. Size your storage accordingly.
Only a SHA-256 hash of the auth secret is stored, and the presented secret is compared in constant time. The full credential is never persisted anywhere — which is why it is displayed exactly once, when the key is created, and can never be retrieved again. If you lose it, revoke the key and mint a new one.
Never transmit the custody secret
Authorization header, in a request body, in a query string or in a log. There is deliberately no field anywhere in the API that accepts it.The custody secret is not a second password for the server. It is key material your client uses locally: you derive a custody keypair from it as HKDF(custodySecret, "custody") and register only the public half with CredenShare. The server therefore has no use for the secret half. We generate it when you create the key, return it in that one response, and never store it or accept it back, so there is nothing on our side to lose.
A credential that arrives with all three parts is refused outright — not tolerated, not silently truncated. The attempt is recorded as a key compromise, because the secret has now been transmitted over the network and may exist in logs, proxies and traces along the way. Treat that key as burned: revoke it and mint a replacement.
Getting a key
Keys are minted in the CredenShare web app, not through the API. Open the account page (/user-account), go to the Developer tab, and find the card headed API keys.
- Click Create API key.
- Enter a Name (required, up to 256 characters). Use something that identifies the caller, like
CI deploy pipeline. - Tick the Scopes the key needs. The form starts with
shares:writeselected. - Choose a Custody level. The form starts on
Self. - Click Create key. The full credential appears once, in a highlighted panel.
- Click Copy credential, store it in your secret manager, then tick the acknowledgement and click Done.
The app registers your custody public key for you, immediately after showing you the credential, while the custody secret is still in memory in your browser. Apart from the one response that delivered the credential to your browser, nothing in the flow sends it.
Scopes
A key carries a set of scopes, chosen at creation and fixed for the life of the key. Matching is exact: there is no hierarchy and no wildcard, so shares:write does not imply shares:read. Grant both if the caller needs both. A key can also be created with no scopes at all. It can then read only its own custody state, through GET /v1/custody, which needs no scope; every other REST endpoint and every MCP tool refuses it.
| Scope | What it gates | Enforced today |
|---|---|---|
shares:read | GET /v1/shares, GET /v1/shares/{shortCode}; MCP tools list_shares, get_share, list_webhooks | Yes |
shares:write | POST /v1/shares, DELETE /v1/shares/{shortCode}; MCP tools create_share_via_browser, expire_share, rotate_webhook_secret | Yes |
requests:read | GET /v1/requests, GET /v1/requests/{shortCode}, GET /v1/requests/{shortCode}/submissions | Yes |
requests:write | POST /v1/requests, DELETE /v1/requests/{shortCode}; MCP tool request_secret_from | Yes |
stats:read | GET /v1/stats; MCP tool share_stats | Yes |
webhooks:manage | Reserved | No — checked nowhere |
webhooks:manage is a reserved name that nothing currently checks. Granting it grants no additional access and withholding it denies nothing — do not treat it as a least-privilege boundary. The two webhook tools are gated on shares:read and shares:write instead, so a key with shares:write can rotate a webhook signing secret even without webhooks:manage, and a key holding only webhooks:manage cannot use either webhook tool.The other five scopes are all enforced. Note in particular that the requests:* scopes are not implied by the shares:* ones: a key that can create shares cannot read a submission unless you granted requests:read as well. The reverse also holds — requests:read gates the submissions endpoint, which is the only read on the API that returns content.
When a key is missing a scope the REST API answers 403 with error code 78 and the message You do not have permission to perform this action on this team. The wording is generic. On this surface it means the key lacks the required scope, or, on a share or request create, that the body's organization_id does not match the key's team (a personal key refuses any organization_id). That second refusal carries its own message.
The MCP endpoint reports the same condition differently. It answers 200 with a tool result marked as an error, whose text is:
This API key lacks the "shares:read" scope, which list_shares requires. Mint a key with that scope.
Custody levels
Custody is the part of this API most likely to surprise you, and it follows directly from the encryption model: content is encrypted on your side, and the server never sees the key. A share you create through the API is readable by whoever holds the link fragment. Custody decides whether a key may also store a wrapped copy of the item key with what it creates. Storing an item_key_wrap does not change how you read the share: your dashboard does not open a share from one, so keep the link if you will need the content again. A key granted account custody can also send an account_key_wrap, which your dashboard does use.
| Level | Name | What is stored for the key | What the caller must do |
|---|---|---|---|
0 | none | No wrap and no envelope | Omit item_key_wrap. Sending one is refused. |
1 | self | Each item_key_wrap sent on a create, saved with that item | Register a custody public key, then send item_key_wrap on each create |
2 | account | As self, plus an account-custody envelope: your account key, wrapped to the key's custody public key. An account_key_wrap sent on a create is saved as your account's copy of that item's key; account_custody in the response says whether it was. | Register a custody public key, then have one of your own devices approve the request |
Level 0 is the API's default. The web app's key-creation form defaults to Self.
Level 0 refuses a wrap outright
A key at level 0 has no registered custody public key. A share create that sends item_key_wrap with it is refused with 400, error code 19, and this message:
This key has custody level 'none', so an item_key_wrap would never be readable.
Mint a key with custody 'self' or omit the wrap.
What a stored wrap does
Sending item_key_wrap at level 1 or higher stores the wrap with the share. It does not let you open the share from your dashboard: the dashboard rebuilds a share's link from a copy of the key wrapped to your account's or your team's zero-knowledge key, and it does not read an item_key_wrap. The API does not return a stored wrap to the key that sent it either. Skip the wrap and the share works the same for its recipient. Storing it does not give you another way back to the share, so keep the link if you will need it. A key at level account can supply the account copy itself, as an account_key_wrap, which your dashboard does use.
The create response reports what happened to the wrap in its custody field:
| Value | Meaning |
|---|---|
none | You sent no item_key_wrap, so no wrap was stored. |
stored | The wrap was saved with the share. This does not make the share readable from your dashboard. |
failed | The wrap could not be saved. The share exists and the response is still 201. |
failed is reported rather than raised as an error on purpose. By that point the share has already been created, so failing the request would make you retry with a fresh idempotency key and mint a second copy of the secret. Storing a wrap also requires that your account has an enrolled zero-knowledge account key and the zero-knowledge plan feature; if either is missing you will see custody: "failed".
Reaching level 1
Every key is created at effective level 0, whatever level you asked for, because a non-zero level requires a custody public key and your client cannot produce one until it holds the credential. The level is raised on first use, when you register the public half of HKDF(custodySecret, "custody"). The web app does this for you as part of key creation.
Reaching level 2
Requesting account custody does not grant it. Like every key, it is created at effective level 0 with the request for level 2 recorded alongside it; registering its custody public key raises it to self, and the effective level rises to account only after one of your devices performs the grant. That device has to hold your account key unwrapped, and the server cannot stand in for it — which is the whole point. An API key can never perform this approval itself.
The custody endpoint
GET /v1/custody
Returns the calling key's custody state. It needs no scope, so any key can discover its own level, and like the other REST endpoints it counts against your rate limit.
{
"key_id": "<keyId>",
"custody_level": 2,
"account_public_key": "<87 characters, base64url>",
"generation": 3,
"account_key_envelope": "<192 characters, base64>"
}
| Field | Type | Notes |
|---|---|---|
key_id | string | Always present. The <keyId> segment of the credential. |
custody_level | integer | Always present. The effective level: 0, 1 or 2. |
custody_level_requested | integer | Present only while account custody has been asked for and not yet approved. It is 2 when present. |
account_public_key | string | Level 2 only, while this key still holds its envelope (see the warning below). Your account public key: the 65-byte uncompressed P-256 point, base64url without padding. It is the recipient for an account_key_wrap. |
generation | integer | Level 2 only, with account_public_key. Which account key account_public_key belongs to. It goes up when your account key is replaced without your old passphrase. |
account_key_envelope | string | Level 2 only, with account_public_key. Your account key, wrapped to this key's custody public key. Sending an account_key_wrap does not need it. Whoever holds the full credential, custody secret included, can open it, which is why granting account custody deserves the care Zero-knowledge custody asks for. |
2 keeps its level but loses its envelope, and this endpoint then returns only key_id and custody_level. Your dashboard can no longer open any wrap made to the old public key, including one sent after the change: a create carrying it is still accepted and reports "account_custody": "stored". Stop sending account_key_wrap when account_public_key is absent or generation has changed. The dashboard does not offer that key for approval again; revoke it, mint a new key with account custody, and approve that one from an activated device.Plans and limits
API access is a Business and Enterprise capability. It is granted by a plan feature, and every other plan resolves to no access at all rather than to an unlimited default.
| Plan | API access | Requests/min | Burst | API keys | Webhook endpoints |
|---|---|---|---|---|---|
| Business (monthly or yearly) | Yes | 100 | 200 | 5 | 3 |
| Enterprise (monthly or yearly) | Yes | 600 | 1200 | 25 | 10 |
| Every other plan | No | — | — | 0 | 0 |
Burst is always twice the sustained rate — there is one number to negotiate, not two. Exceeding the rate limit returns 429 with a Retry-After header. Webhooks are gated by the same plan feature as the API; there is no separate webhook entitlement. CredenShare can raise any of these numbers for an individual account or organization.
If a key acts inside an organization, its API access, rate limit and key allowance come from the organization's plan, not from the plan attached to the individual member.
Trying to mint a key on a plan without API access returns 403 with error code 98:
{"success":false,"message":"API access requires a Business or Enterprise plan","error_code":98}
The Create API key button is visible on every plan, so this error is how a plan without API access finds out. Reaching your plan's key allowance returns 403 with error code 100 and the message You have reached the API key limit for your plan.
Keys that act in a team
A key can be bound to a team. It then acts in that team, and its API access, rate limit and key allowance come from the team's plan (see Plans and limits). A key that is not bound to a team is a personal key, and the membership rules below do not apply to it. The Create API key form on the Developer tab has no team option, so the keys it makes are personal.
To mint a key for a team you must be a confirmed member of it. A mint that names a team you have not joined, a team whose invitation you have not accepted yet, a team that does not exist, or a value that is not a team id gets 403 with error code 78. All four get the same answer. If your membership cannot be checked at that moment, the mint fails with 500 and error code 45, and no key is created; try again.
Every request made with a team key, to the REST API or to the MCP server, checks that its holder is still a confirmed member of the team, and the answer is not cached. From the first request after the holder stops being a confirmed member, the key is refused with the same 403 as any other credential that cannot be used; see When authentication fails. A brief problem on our side while the membership is checked produces that 403 too. If a key was working moments ago, retry with backoff before treating it as revoked.
CredenShare also revokes the key when its holder is removed from the team or when the team is deleted. It then shows the Revoked badge in the holder's API keys list, and its account-custody envelope is removed, as when you revoke a key yourself. Keys the holder has that are not bound to that team are not revoked.
If a share or request create sends organization_id, it must match the key's team; letter case is ignored. A create that sends organization_id with a personal key is refused.
Rotating and revoking a key
There is no rotate operation for API keys, and no way to change the scopes or the name of an existing one. Rotation is mint-then-revoke:
- Create a new key with the same scopes and custody level.
- Deploy it. Both keys work at the same time, so there is no window where your caller has no credential.
- Confirm the new key is being used, then revoke the old one.
To revoke, open the API keys card and click Revoke on that key's row. Each row shows the key's name, its key id and scopes, a Custody badge when its custody level is 1 or 2, and when it was last used — or never used — which is a good way to find keys nothing depends on before you remove them.
Revocation is immediate. The credential is re-verified from the database on every request and nothing about it is cached, so a revoked key stops working on the next call rather than at the end of some cache window. A revoked key shows a Revoked badge and no longer counts against your plan's key allowance, so revoking frees a slot right away. Revoking also removes the key's account-custody envelope, the copy of your account key that was wrapped to the key's custody public key. CredenShare also revokes a team key automatically when its holder is removed from the team or the team is deleted; see Keys that act in a team.
Rotate immediately if the full three-part credential was ever transmitted, pasted into a log or ticket, or committed to a repository.
When authentication fails
Authentication failures are answered by the gateway, before your request reaches the API, so they do not use CredenShare's normal JSON error envelope. Do not parse them as though they carry a numeric error_code.
| Situation | Status | Body |
|---|---|---|
No Authorization header | 401 | {"message":"Unauthorized"} |
| Any credential that is not currently usable | 403 | {"Message":"User is not authorized to access this resource with an explicit deny in an identity-based policy"} |
"Not currently usable" deliberately covers every reason at once: a malformed credential, an unknown key id, a wrong auth secret, a revoked key, a credential that carried the custody secret, a key on an account without API access, and a team key whose holder is no longer a confirmed member of that team all produce the identical answer. That uniformity is intentional — a caller who could tell "this key id does not exist" from "this key id exists but is revoked" could probe for valid key ids. It also means the response cannot tell you why you were refused, so check the obvious causes in order: the prefix and the two-part shape, then whether the key was revoked, then whether the account's plan includes API access (for a team key, the team's plan), then, for a team key, whether its holder is still a confirmed member of the team.
A brief problem on our side can also produce this 403, for example when a team key's membership cannot be checked or your plan cannot be looked up. If a key was working moments ago, retry with backoff before treating it as revoked.
One timing detail worth knowing: the credential itself is never cached, but the plan entitlement behind it is cached briefly. A key you have just revoked stops working immediately, while a plan change — including newly granted API access — can take up to 30 seconds to take effect. A team key's membership is not cached either, so removing its holder from the team takes effect on the key's next request.