Create a request
POST /v1/requests
Requires the requests:write scope and an Idempotency-Key header. Returns a short_code from which you assemble the collect link.
/v1/requests exactly — never to /v1/requests/{shortCode}. A short code in the path is currently ignored, not rejected: the call is handled as a create, so POST /v1/requests/a1b2c3d4 mints a brand-new request with a brand-new short code and returns 201. There is no update endpoint for a secure request — to change one, expire it and create a replacement.Request
curl -X POST https://api.credenshare.io/v1/requests \
-H "Authorization: Bearer crs_sk_live_<keyId>.<authSecret>" \
-H "Idempotency-Key: onboard-acme-2026-09-03" \
-H "Content-Type: application/json" \
-d '{
"title": "Production database password",
"description": "We need the read-only credential for the reporting job.",
"public_key": "<base64url P-256 public key>",
"fields": [
{ "item": "Username", "type": "text" },
{ "item": "Password", "type": "password" }
],
"expired_at": "2026-09-30T12:00:00Z",
"max_submission": 1
}'
Body
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | Yes | Max 256 characters. Shown to the person filling the form. |
public_key | string | Yes | The request's P-256 public key, base64url, max 128 characters. You generate it; keep the private half. |
fields | array | Yes | At least one. What the person is asked for — see below. |
description | string | No | Max 2000 characters. |
passcode | string | No | An extra secret the submitter must enter before the form opens. At most 72 bytes: a longer one is refused with 400, error_code 19, before the Idempotency-Key is claimed, so you can resend the corrected body under the same key. |
max_submission | integer | No | Minimum 1. How many times the link may be submitted. |
expired_at | string | No | RFC 3339. Defaults to 30 days — see below. |
access_counts_left | integer | No | Minimum 1. How many times the link may be opened. |
requires_login | boolean | No | Submitter must be signed in to CredenShare. |
requires_mfa | boolean | No | Submitter must be signed in, with MFA on their CredenShare account. Implies requires_login. |
restricted_domain | array of string | No | Email domains permitted to submit. |
ip_whitelist | array of string | No | Source addresses permitted to submit. |
organization_id | string | No | Accepted only when it agrees with the key's own organization. See below. |
account_key_wrap | string | No | The request's seed wrapped to your account public key, so your dashboard can open its submissions. Needs a key with account custody. See below. |
item_key_wrap | string | No | The seed wrapped to your key's custody public key, under the same custody rules as on share creates; a none-custody key is refused with a different body here (see Errors). Nothing reads it back. |
There is no created_by and no id. The acting key decides the owner; you do not get to name it.
fields
fields is required and must contain at least one entry. Each entry is:
| Field | Type | Required | Notes |
|---|---|---|---|
item | string | Yes | The visible prompt: "Staging database password". Max 256 characters. |
type | string | No | How the field renders. Defaults to text. Max 32 characters. |
item, not label or name — the same distinction as a share field's key, and the same trap. And fields being required is not bureaucracy: a request created without them returns 201 and a working short code, while the person you send the link to gets a dead page reading "Unable to Load Request — this request has no fields configured". Nothing at the API level would show you that; it was found by opening the link in a browser.expired_at defaults to 30 days
Omit expired_at and the request expires 30 days from creation, matching what the app has always defaulted to.
This is a real default, not a formality. A request stored with no expiry is invisible: the list query is expired_at > current_timestamp, and NULL satisfies neither that nor its negation, so such a request is neither active nor expired. It would be created successfully, readable by short code, and absent from both the API list and the owner's own dashboard. The alternative — a collect link that stays open forever — is not a sensible thing to get by leaving a field out either.
organization_id
Send it only if it matches the organization your key already acts for, or omit it and let the key decide. Letter case is ignored in the comparison. A mismatch is refused:
{
"success": false,
"message": "organization_id does not match the organization this key acts for. Omit it — the key determines the organization — or mint a key bound to that organization.",
"error_code": 78
}
Taking this field from the body unchecked was an authorization bypass on the shares surface once. It is validated here for the same reason.
account_key_wrap
Submissions are sealed to the request's public key, and whoever holds the private half can read them. account_key_wrap stores that private half for your account, so your dashboard can open the request's submissions on any device you have activated, without the access link. The submission viewer looks for that copy only once your zero-knowledge state has loaded in the browser tab. If a request's submissions stop at Access link needed although the copy is stored, open Account → Security, then go back to the request without reloading the page.
That only works if the request's keypair comes from a 32-byte seed, derived the way the dashboard derives it:
seed = 32 random bytes
wide = HKDF-SHA256(seed, "", "crs-ecdh-p256-scalar", 48)
scalar = (bigEndianInt(wide) mod (n - 1)) + 1 // n = the P-256 group order
public_key = base64url(scalar · G, uncompressed: 0x04 || X(32) || Y(32))
Send that public_key on the create, and send as account_key_wrap the raw 32-byte seed wrapped to the account_public_key that GET /v1/custody returns, with the wrap construction shares use, as standard base64 with padding. If the keypair was made any other way, the wrap is still stored, but the dashboard treats the unwrapped bytes as a seed and derives a different private key from them, so it cannot open any submission.
The rest follows share creates: only a key at custody level account can send it (otherwise 403, error code 112), the server stores it without checking that it opens, and the outcome comes back in account_custody. The checks run in a different order from shares, though. After field validation, a mismatched organization_id is refused first (403, code 78), then an item_key_wrap from a key at level none (400, code 19), then an account_key_wrap from a key below account (403, code 112). So a level-none key that sends both wraps gets the 400 here, where a share create returns the 403.
Keep your own copy of the seed as well: the submissions endpoint returns ciphertext, and the API never returns either wrap, so a script that reads submissions can decrypt them only with the seed it kept.
Idempotency
The Idempotency-Key header is required, and more consequentially here than on shares: a duplicated request is not inert, because a human can fill it in. Two collect links in the world when you believe you created one means the same credential can be handed over twice.
The semantics are identical to share creates — the raw request body is hashed byte for byte, keys are scoped to the API key that used them and retained for 24 hours:
| Situation | Result |
|---|---|
| Header missing or whitespace only | 400, error_code 104 |
| Key reused, body byte-identical, first call finished | 200 with the first call's response replayed verbatim |
| Key reused, body differs by even one byte | 409, error_code 105 |
| Key reused while the first call is in flight | 409, error_code 106 |
The first call failed while the request was being stored (a 500) | The key stays claimed: retry under a new key, because reusing it returns 409 |
A replay returns 200, not 201. Treat both as success.
Success response
201 Created:
{
"short_code": "a1b2c3d4",
"expired_at": "2026-09-30T12:00:00Z",
"public_key": "<the key you sent>",
"custody": "none",
"account_custody": "none"
}
There is no url field, for the same reason share creates have none: only you know which origin your recipients use. Assemble it yourself:
https://crs.sh/r/a1b2c3d4
That link carries no key material and is safe to send over ordinary channels. public_key comes back so you can confirm what was stored is the key you generated. custody and account_custody report what happened to item_key_wrap and account_key_wrap, with the same values as on share creates.
Errors
| Status | error_code | When |
|---|---|---|
| 400 | 6 | Body is not valid JSON. |
| 400 | 19 | Validation failed. additional_data names each rejected field — a missing public_key or an empty fields array shows up here. |
| 400 | 19 | An item_key_wrap was sent by a key at custody level none. additional_data names the field. |
| 400 | 104 | No Idempotency-Key header. |
| 403 | 78 | The key lacks requests:write, or organization_id disagreed with the key's organization. |
| 403 | 112 | An account_key_wrap was sent by a key below custody level account. |
| 403 | 138 | The plan does not include secure requests: the account's plan, or with organization_id the team's. Business and Enterprise include them. Nothing was created; this Idempotency-Key stays claimed. |
| 409 | 105 | Idempotency-Key reused with a different body. |
| 409 | 106 | Idempotency-Key reused while the first call is still running. |
| 429 | 107 | Rate limit exceeded. Retry-After gives the seconds to wait. |
| 500 | 7 | The passcode could not be hashed. Nothing was created. Retry under a new Idempotency-Key; this one stays claimed. |
| 500 | 11 | The request could not be stored. |
403 with code 78 is ambiguous by design — the generic message covers both a missing scope and an organization mismatch. The message field tells the two apart; the code does not.
Next
Give the person the collect link, then read the submissions once they have filled it in. Subscribe to request.submitted if you would rather be told than poll — see Webhook events.