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. |
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 clear MFA. |
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. |
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. 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:
| 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 |
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
| 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 | 104 | No Idempotency-Key header. |
| 403 | 78 | The key lacks requests:write, or organization_id disagreed with the key's organization. |
| 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 | 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.