# Secrets

A **secret** is something about a payer that a purchase may need and nobody
else should see: a shipping address, a phone number, a shop password, a
loyalty number. You store it once, and an [intent](/docs/intents) names it.
The agent that reads the shop's page is given only a handle (`sec_…`), its
label and a mask. The value is typed into the page by the worker, and it is
**never returned** by any endpoint, tool, event or webhook.

<Callout type="warning">
Secrets are write-only. You can create, replace, list and delete them. There
is no way to read one back, so keep your own copy if you need it.
</Callout>

<Callout>
The values you send pass through your request, and through an MCP tool call
they pass through the model's context. Where the payer can enter a value
themselves, prefer that to sending it through an agent.
</Callout>

## The secret object

```json
{
  "id": "sec_5a1c93d2e0b4",
  "object": "secret",
  "customer": "cus_42",
  "kind": "address",
  "label": "Home",
  "mask": "London, GB",
  "public": { "city": "London", "country": "GB" },
  "shops": [],
  "default": false,
  "mode": "live",
  "entered_by": "developer",
  "version": 1,
  "created_at": "2026-10-13T08:30:00Z",
  "updated_at": "2026-10-13T08:30:00Z",
  "expires_at": null,
  "last_used_at": null
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | The handle, prefixed `sec_`. |
| `customer` | string | The customer it belongs to; `null` for your account's own. |
| `kind` | string | One of the kinds below. |
| `label` | string | The name you gave it. Agents see it, so never put a value in it. |
| `mask` | string | What a person may be shown to recognise it, such as `London, GB`. |
| `public` | object | The few fields kept in the clear (a city, a loyalty program). |
| `shops` | array | The shops (domains) it may be used on. Empty: any shop the purchase names. |
| `default` | boolean | Offered first among the payer's secrets of its kind. |
| `mode` | string | `test` or `live`, from the key that wrote it. A test key never sees a live secret. |
| `entered_by` | string | `developer` (the API) or `payer` (their own page). |
| `version` | integer | Counts replacements. |

## Kinds and fields

Every `fields` value is text. Fields not listed for a kind are refused.

| Kind | Fields (sealed) | Public |
| --- | --- | --- |
| `address` | `recipient_name`, `line1`, `line2`, `postal_code`, `phone` | `city`, `region`, `country` |
| `contact` | `given_name`, `family_name`, `email`, `phone` | none |
| `identity_document` | `number`, `full_name`, `date_of_birth`, `expiry` | `document_type`, `issuing_country` |
| `shop_password` | `username`, `password` | `shop` |
| `loyalty` | `member_number`, `pin` | `program` |

A `shop_password` needs exactly one entry in `shops`, and is used nowhere
else. A payer may have up to 50 secrets in each mode, and one secret may hold
at most 4 KB.

## Create a secret

<Endpoint method="POST" path="/v1/secrets" />

```bash
curl https://api.openintents.io/v1/secrets \
  -H "Authorization: Bearer $OPENINTENTS_API_KEY" \
  -H "Idempotency-Key: addr-cus_42-1" \
  -H "Content-Type: application/json" \
  -d '{
    "customer": "cus_42",
    "kind": "address",
    "label": "Home",
    "fields": {
      "recipient_name": "Ada Lovelace",
      "line1": "12 Example Street",
      "postal_code": "N1 9GU",
      "city": "London",
      "country": "GB"
    }
  }'
```

| Parameter | Type | Description |
| --- | --- | --- |
| `customer` | string | The customer it is for. Leave out for your account's own. |
| `kind` | string | Required. |
| `label` | string | Required, at most 60 characters. |
| `fields` | object | Required. The kind's fields, as text. |
| `shops` | array | Domains it may be used on. |
| `default` | boolean | Make it the payer's default for its kind. |
| `expires_at` | string | An RFC 3339 time after which it is deleted. |

It answers `201` with the secret object. A request with an
`Idempotency-Key` that is sent again with the same body gets the same
answer. The same key with **any** change in the body, values included, is
`409`. The comparison is made on a keyed digest of the whole request, and the
stored record holds nothing from which a value could be guessed.

## Replace a secret

<Endpoint method="PUT" path="/v1/secrets/{id}" />

Sends the whole secret again, in the same shape as a create. There is no
partial update. The old values are deleted and `version` goes up. A secret
cannot move to another customer.

## List and get

<Endpoint method="GET" path="/v1/secrets?customer=cus_42&kind=address" />
<Endpoint method="GET" path="/v1/secrets/{id}" />

Both return the object above and nothing more. `customer` and `kind` narrow a
list. Without `customer`, the list is your account's own.

## Delete a secret

<Endpoint method="DELETE" path="/v1/secrets/{id}" />

Answers `204`. The values are gone for good.

## See how a secret was used

<Endpoint method="GET" path="/v1/secrets/{id}/uses" />

Every use of a secret, newest first, paged with `limit` and `cursor`: when it
was created, replaced or deleted, when a worker typed a field into a shop, and
when a use was turned away. Each `secret_use` has `action` (`create`,
`replace`, `delete`, `expire`, `fill` or `refuse`), `actor` (`worker`, `api`,
`payer`, `developer` or `system`), `outcome` (`ok` or `refused`), the `reason` for a
refusal (`shop_not_allowed`, `expired` or `wrong_use`), and where they apply
the `field`, `field_names`, `shop` and `intent`. It never holds a value or a
mask. The history of a deleted secret stays readable. Your dashboard shows the
same under Secrets.

## Let the payer enter it themselves

To keep a value out of your code and your agent altogether, ask the payer to
type it on a page of ours:

```bash
curl https://api.openintents.io/v1/customers/cus_8f2a/secret_requests \
  -H "Authorization: Bearer $OPENINTENTS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "kinds": ["address", "contact"], "return_url": "https://app.example.com/done" }'
```

```json
{
  "id": "sr_Q7mX2kLp0aVbN4cR8sTuYw1e",
  "object": "secret_request",
  "customer": "cus_8f2a",
  "kinds": ["address", "contact"],
  "url": "https://openintents.io/vault/sr_Q7mX2kLp0aVbN4cR8sTuYw1e",
  "expires_at": "2026-01-02T10:00:00Z"
}
```

Send the payer to `url`. It works once, and `ttl_seconds` (60 to 604800,
default one day) sets how long it lasts. The page sends what the payer types
straight to OpenIntents, sealed, with no referrer, and never shows it back.
The secrets it makes have `entered_by: payer`.

When the payer is done, an endpoint that names `secret_request.completed`
is sent the request with the handles it made (no values). You can also read
it with `GET /v1/secret_requests/{id}`: `state` is `pending`, `done` or
`expired`, and `secrets` lists the handles.

## Use secrets in an intent

On [`POST /v1/intents`](/docs/intents), two optional parameters say what the
purchase may use:

| Parameter | Type | Description |
| --- | --- | --- |
| `secrets` | array | Handles of the payer's secrets the purchase may use. Left out: the payer's defaults. `[]`: none. |
| `shops` | array | Domains you let the purchase fill secrets on. `["*"]` allows any shop, for contact and address secrets only. |

A handle that is not the payer's own, is from the other mode, or does not
exist is `404` with `param: secrets`. An expired one is `400`.

## Errors

| Status | Code | When |
| --- | --- | --- |
| `400` | `invalid_request` | A field fails its rule. The error names the field and the rule, never the value. |
| `404` | `not_found` | The secret is not yours, is from the other mode, or is gone. |
| `409` | `idempotency_key_reused` | The `Idempotency-Key` was used with a different body. |
| `409` | `too_many_secrets` | The payer already has 50 in that mode. |
| `413` | `invalid_request` | The request is over 16 KB. |
| `429` | `too_many_writes` | A key may write 60 secrets a minute. `Retry-After` says when to try again. |
| `503` | `secrets_unavailable` | Secrets cannot be stored right now. |

## MCP tools

The [MCP server](/docs/mcp) has `create_secret`, `list_secrets` and
`delete_secret`, with the same arguments and results. There is no tool that
reads a secret.
