Create a share
POST /v1/shares
Requires the shares:write scope. Creates a share from ciphertext your client produced, and returns the short code you use to assemble the recipient link.
If you have not read the end-to-end flow, start there — four of the five steps in a create happen on your side, and this page only covers the request itself.
The Idempotency-Key header is required
Idempotency-Key: deploy-42
400 and error_code 104.It is required because a retried automation must not duplicate a secret. A second share is a second copy of a credential in the world, with its own link and its own audit trail, that your caller does not know exists.
Any unique string you can reproduce on retry will do. The value is scoped to your API key, so it cannot collide with another customer's, and it is retained for 24 hours.
Request fields
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | yes | Max 256 characters. Visible to CredenShare — it is metadata, not encrypted content. |
data | string | yes | Your ciphertext. See below. |
encryption_type | string | yes | Exactly one accepted value: e2ee-aes256-gcm. |
access_token | string | yes | Derived from the content key. See below. |
description | string | no | Max 1024 characters. Also visible to CredenShare. |
passcode_verifier | string | no | A derived verifier, never the passcode. See below. |
expired_at | string | no | When the share stops working, at most 366 days after the request. Accepted formats below. Omit it and the share expires 30 days after creation. |
access_counts_left | integer | no | View limit, from 1 to 10000. |
timed_view | integer | no | Seconds the content stays visible once opened, from 5 to 86400. |
organization_id | string | no | Omit it. The organization is taken from the key, and a value that disagrees is refused — see below. Letter case is ignored in the comparison. |
item_key_wrap | string | no | The content key wrapped to your key's custody public key. The API does not return it and the dashboard does not use it. See below. |
account_key_wrap | string | no | The content key wrapped to your account public key: the copy your dashboard uses to rebuild the link. Needs a key with account custody. See below. |
organization_id that matches, ignoring letter
case, it is accepted; if you send one that does not, the create is refused with 403 and error
code 78 rather than quietly filed somewhere else. A key with no team binding cannot attribute
a share to one at all. A team key is refused once its holder is no longer a confirmed member of
the team; see Keys that act in a team.Keep secrets out of title and description. Those two fields are exactly the ones we can read.
data
data is standard base64 of salt(16) || iv(12) || ciphertext+tag, as described in the flow.
CredenShare stores and serves this string verbatim. It is not parsed, not re-encoded and not encrypted again — there is no server-side encryption step on this path, because the content arrived already encrypted.
Nothing validates its internal structure either, so a malformed blob is accepted on create and fails only when a recipient tries to open it. Verify a new integration by opening one of its shares, not by trusting the 201.
access_token
access_token = base64url(HKDF-SHA256(contentKey, "", "access", 32))
This is the value the recipient must present to read the share, and it is what binds a link to its content. Their browser recomputes it from the key in the URL fragment, so you do not send it to the recipient separately.
CredenShare stores only SHA-256 of the token's trimmed value. The token itself is not retained, and it is not a decryption key — possession of it proves possession of the link and nothing more.
passcode_verifier
passcode_verifier = base64url(HKDF-SHA256(utf8(passcode), "", "verify", 32))
Send the verifier, never the passcode itself. The passcode is mixed into the derivation of your content key, so handing us the passcode would hand us a component of that key — which is the whole thing this design exists to avoid. The verifier is one-way, so the server can check an attempt without gaining the ability to decrypt.
Passcode protection is a plan feature. A create carrying a passcode_verifier on a plan without view protection is refused with a 403 and error_code 53.
item_key_wrap
An API-created share is readable from its link, because the link carries the key. item_key_wrap is the content key wrapped to your own key's custody public key, and it is stored with the share. Storing it does not add another way to read the share: the dashboard does not use this wrap to rebuild the share's link, and the API does not return it. For a copy the dashboard does use, send an account_key_wrap.
Only you can compute it. The wrap is made to the custody keypair derived from the third part of your credential, which we return to you once when the key is created and never store, so we can store the wrap without being able to open it.
Two conditions apply:
- Your API key must be at custody level
selforaccount. A key at custody levelnonesending a wrap gets a400with the messageThis key has custody level 'none', so an item_key_wrap would never be readable. Mint a key with custody 'self' or omit the wrap.The create is refused, not accepted with the wrap silently dropped, so anone-custody key fails on its first create that sends a wrap. - Your account must have a zero-knowledge account key enrolled, and the zero-knowledge plan feature. When that is not the case the share is still created and the response reports
"custody": "failed".
The subject of the wrap is always the acting API key, taken from the verified credential. There is no request field for it and no way to name a different one.
Sending item_key_wrap does not replace keeping the link: the link is how the share is read.
account_key_wrap
account_key_wrap is the copy your dashboard uses. It is the content key wrapped to your account public key, the same key zero-knowledge custody uses for the shares you create in the browser, and it is stored as your account's copy of the key. On any device you have activated, the copy-link and QR actions in your Shares list can then rebuild the share's link.
Only a key at custody level account can send it, and the API gives your account public key only to such a key: it is the account_public_key that GET /v1/custody returns. A key below that level is refused with 403, error code 112, and this message:
This key does not hold account custody, so an account_key_wrap could never be read.
Approve account custody for the key from a device first, or omit the wrap.
Build it with the same wrap construction as item_key_wrap. Only the recipient changes, and the payload has to be exact:
- The recipient is
account_public_key, not your key's custody public key. It is base64url without padding; decode it to the 65-byte uncompressed P-256 point. - The payload is exactly the raw 32 bytes of the content key, the same bytes the link fragment carries — not their base64, not the fragment, and not a key derived from a passcode.
Send the result as standard base64 with padding: 192 characters for a 32-byte key. The dashboard decodes it with a standard base64 decoder, which rejects the - and _ characters base64url uses.
The server stores the string without checking that it opens, so "account_custody": "stored" means it was saved, not that it unwraps. A wrap with the wrong payload still unwraps, and the dashboard then copies a link that does not open. When you first wire this up, copy one share's link from the dashboard and open it.
It is your copy, not your team's. The API writes no team wrap, so no one else in the team can rebuild the link of a share that a key bound to that team created — not even the team's owners and admins, who see the share in the team's list.
account_key_wrap and item_key_wrap are independent. A key at level account can send either or both, and each is reported in its own response field. If your account key is ever replaced without your old passphrase, wraps made to the old public key stop opening in your dashboard, so read GET /v1/custody again before relying on a cached account_public_key, and stop sending the wrap if that field is absent.
expired_at
expired_at is a datetime, not a duration. Values like 24h are rejected. It may be at most 366 days away: a later date is refused with a 400 and error_code 139, and nothing is created.
| Format | Example |
|---|---|
| RFC 3339 with fractional seconds | 2026-09-01T00:00:00.123456789Z |
| RFC 3339 with a zone or offset | 2026-09-01T00:00:00Z or 2026-09-01T02:00:00+02:00 |
| Date and time with no zone | 2026-09-01T00:00:00 |
| Date only | 2026-09-01 |
A value with no timezone is interpreted as UTC. An unparseable value is a 400.
This API has no First View option. No request field starts the countdown at the first open, and an expired_in_seconds_after_opened in the body is ignored rather than refused, so a share sent without expired_at gets the 30-day default.
Request
curl -X POST https://api.credenshare.io/v1/shares \
-H "Authorization: Bearer crs_sk_live_<keyId>.<authSecret>" \
-H "Idempotency-Key: deploy-42" \
-H "Content-Type: application/json" \
-d '{
"title": "Deploy credentials",
"encryption_type": "e2ee-aes256-gcm",
"data": "oKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u...",
"access_token": "d2DJ6L1GBXjLD-YhpdyVxJQNkLOHIovvBRUbSMcZf0A",
"expired_at": "2026-09-01T00:00:00Z",
"access_counts_left": 1
}'
Success response
201 Created, with four keys:
{
"short_code": "a1b2c3d4",
"expired_at": "2026-09-01T00:00:00Z",
"custody": "none",
"account_custody": "none"
}
| Field | Type | Notes |
|---|---|---|
short_code | string | The share's public identifier. Use it to build the link, and as the path parameter on the other endpoints. |
expired_at | string | RFC 3339. If you sent no expired_at, this is the default the share was given: 30 days after creation. |
custody | string | What happened to item_key_wrap: none, stored or failed. |
account_custody | string | What happened to account_key_wrap: none, stored or failed. |
url field and no id field. The response returns a short code because a link would have to contain the content key, which CredenShare never receives in the clear. Assemble the link yourself as https://crs.sh/{short_code}#1{base64url(contentKey)}.The custody fields
custody reports item_key_wrap, and account_custody reports account_key_wrap. Both take the same three values:
| Value | Meaning |
|---|---|
none | That wrap was not sent. |
stored | The wrap was saved. An account_key_wrap is then the copy your dashboard uses; an item_key_wrap does not change how the share is read. |
failed | The share exists, but the wrap could not be stored. The share and its link work as normal. |
failed comes back with a 201, not an error. By the time the wrap is attempted the share already exists, so reporting a failure would make you retry with a fresh idempotency key and mint a second copy of the secret. The wrap is additive: a failed wrap does not affect the share or its link. Either wrap comes back failed when, for example, your account lacks the zero-knowledge plan feature or has no enrolled zero-knowledge account key.
Idempotent replay
Repeating a create with an Idempotency-Key that has already completed returns 200 OK — not 201 — with the first call's response body verbatim, including the same short code.
The request body is fingerprinted with SHA-256 over the raw bytes received. A retry must therefore be byte-identical: reformatted JSON or reordered keys count as a different body and are refused with a 409, even when the meaning is unchanged. Send the same serialized bytes you sent the first time.
Claims are scoped to your API key and expire after 24 hours.
Order of checks
A create runs its checks in a fixed order, and stops at the first failure. This matters when you are debugging a status code you did not expect:
- Scope — is
shares:writeon the key? Idempotency-Key— is the header present?- JSON — does the body parse?
- Field validation — are the required fields present and within their limits?
- Plan share allowance — do you have shares left this period?
encryption_type— is ite2ee-aes256-gcm?account_key_wrap— if sent, does the key hold custody levelaccount?item_key_wrap— if sent, is the key's custody level abovenone?organization_id— if sent, does it match the key's team? A personal key refuses any value.- Idempotency claim.
So a request that sends the wrong encryption_type while your plan's share allowance is spent returns the 403 for the allowance, not the 400 for the encryption type. Fix the earlier check first and the later one will surface.
Errors
| Status | error_code | When |
|---|---|---|
| 400 | 104 | The Idempotency-Key header is missing or blank. |
| 400 | 19 | Field validation failed. additional_data maps each rejected field to a message. |
| 400 | 19 | encryption_type is not e2ee-aes256-gcm, or an item_key_wrap was sent by a none-custody key. Both carry their own message. |
| 400 | 6 | The body is not valid JSON, or a field has the wrong JSON type. |
| 400 | 139 | expired_at is more than 366 days after the request. |
| 403 | 78 | The key lacks the shares:write scope, or organization_id did not match the key's team. The message tells the two apart. |
| 403 | 112 | An account_key_wrap was sent by a key below custody level account. |
| 403 | 61 | Your plan's share allowance is spent. API creates count against the same allowance the dashboard enforces. |
| 403 | 53 | A gated feature was requested without the plan for it, such as passcode_verifier without view protection. |
| 409 | 105 | This Idempotency-Key was already used with a different request body. |
| 409 | 106 | An earlier request with this Idempotency-Key has not finished. |
| 429 | 107 | Rate limit exceeded. Retry-After gives the seconds to wait, and is never below 1. |
| 500 | 24 or 45 | The account or team behind the plan lookup could not be read. |
| 503 | 108 | Your entitlements could not be resolved, so we cannot tell whether this create is within your allowance. Nothing was created. Retry — a failed billing lookup is deliberately not treated as permission to exceed a cap. |
Team shares and quota
A key can act inside a team. When it does, the plan consulted for the share allowance is the team's plan, not the key holder's own account — a seat member of a paying team is judged against the team.
API creates consume the same monthly share allowance as the dashboard. The API is not a way around a plan limit. max_shares = -1 on a plan means unlimited.
Webhooks
A successful create emits the share.created event to your webhook endpoints, carrying short_code plus title, organization_id and expired_at where those are set. The payload is metadata only; ciphertext, keys, tokens and fragments are never included in any webhook body.