Skip to content
CredenShare
Secure requests

Create a request

POST /v1/requests — mint a collect link that lets somebody hand you a secret you can decrypt and we cannot.
POST /v1/requests

Requires the requests:write scope and an Idempotency-Key header. Returns a short_code from which you assemble the collect link.

Post to /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

FieldTypeRequiredNotes
titlestringYesMax 256 characters. Shown to the person filling the form.
public_keystringYesThe request's P-256 public key, base64url, max 128 characters. You generate it; keep the private half.
fieldsarrayYesAt least one. What the person is asked for — see below.
descriptionstringNoMax 2000 characters.
passcodestringNoAn extra secret the submitter must enter before the form opens.
max_submissionintegerNoMinimum 1. How many times the link may be submitted.
expired_atstringNoRFC 3339. Defaults to 30 days — see below.
access_counts_leftintegerNoMinimum 1. How many times the link may be opened.
requires_loginbooleanNoSubmitter must be signed in to CredenShare.
requires_mfabooleanNoSubmitter must clear MFA.
restricted_domainarray of stringNoEmail domains permitted to submit.
ip_whitelistarray of stringNoSource addresses permitted to submit.
organization_idstringNoAccepted only when it agrees with the key's own organization. See below.

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:

FieldTypeRequiredNotes
itemstringYesThe visible prompt: "Staging database password". Max 256 characters.
typestringNoHow the field renders. Defaults to text. Max 32 characters.
The prompt key is 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. 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.

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:

SituationResult
Header missing or whitespace only400, error_code 104
Key reused, body byte-identical, first call finished200 with the first call's response replayed verbatim
Key reused, body differs by even one byte409, error_code 105
Key reused while the first call is in flight409, error_code 106

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>"
}

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.

Errors

Statuserror_codeWhen
4006Body is not valid JSON.
40019Validation failed. additional_data names each rejected field — a missing public_key or an empty fields array shows up here.
400104No Idempotency-Key header.
40378The key lacks requests:write, or organization_id disagreed with the key's organization.
409105Idempotency-Key reused with a different body.
409106Idempotency-Key reused while the first call is still running.
429107Rate limit exceeded. Retry-After gives the seconds to wait.
50011The 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.