# REST API

A small JSON API over HTTPS.

```text
https://api.openintents.io/v1
```

## Authentication

Send your [API key](/dashboard/api-keys) as a Bearer token. `oi_test_` keys
create test intents; `oi_live_` keys make real purchases.

```bash
curl https://api.openintents.io/v1/intents \
  -H "Authorization: Bearer $OPENINTENTS_API_KEY"
```

## Conventions

- Amounts are **integers in the smallest currency unit** (cents for USD).
- Timestamps are ISO 8601 in UTC.
- Creates, cancels and answers accept an `Idempotency-Key` header (up to 255
  printable ASCII characters). Retrying with the same key returns the first
  response instead of doing the work twice. Use it for anything that spends
  money. The same key with a different body is `409 idempotency_key_reused`;
  the same key while the first request is still running is
  `409 idempotency_key_in_use`.
- Lists are cursor-paginated: pass `limit` (max 100) and the `next_cursor` from
  the previous page.
- Every request is counted against the key's [rate limit](#rate-limits).

## Customers

A **customer** is one of your own users, kept as a payer of its own: each has
the [wallet](/docs/wallets) their purchases are paid from,
[browser profiles](/docs/profiles) and [secrets](/docs/vault), apart from your
other customers and from your account. Pass `customer` (the `cus_…` ID) to act
for one: when you create an intent or list intents.
Without it the call acts for your account. A customer ID that is not one of
your account's is `400` with `param: "customer"`.

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

| Parameter | Type | |
| --- | --- | --- |
| `external_id` | string | Your own ID for this user, up to 200 characters. Unique in your account: a second customer with the same one is `409`. |
| `name` | string | Up to 200 characters. Filled in for them when they set up their [wallet](/docs/wallets). |
| `email` | string | Up to 320 characters. Filled in for them when they set up their [wallet](/docs/wallets). |
| `metadata` | object | Up to 20 string pairs, keys 1 to 40 characters, values up to 500. |
| `limits` | object | `{ "per_intent", "monthly" }` in cents: the most one purchase may cost, and the most the purchases paid from the payer's wallet may commit in one UTC calendar month (each counts its cart's total). They are checked before anything is paid: an intent over one fails with `limit_exceeded`, and nothing the payer does in their wallet lifts them. See [keeping spend in check](/docs/wallets#keeping-spend-in-check). |

```bash
curl https://api.openintents.io/v1/customers \
  -H "Authorization: Bearer $OPENINTENTS_API_KEY" \
  -H "Idempotency-Key: 0a4c1d1e-signup-u42" \
  -H "Content-Type: application/json" \
  -d '{ "external_id": "u_42", "name": "Ada Lovelace", "email": "ada@example.com" }'
```

```json
{
  "id": "cus_8f2a",
  "object": "customer",
  "external_id": "u_42",
  "name": "Ada Lovelace",
  "email": "ada@example.com",
  "metadata": {},
  "limits": { "per_intent": null, "monthly": null },
  "created_at": "2026-10-01T08:12:00Z"
}
```

Returns the customer with HTTP `201`.

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

Returns the customer.

<Endpoint method="PATCH" path="/v1/customers/{id}" />

Takes the same fields as create. A field left out is kept, an empty string
clears `external_id`, `name` or `email`, `metadata` replaces the whole map, and
inside `limits` only the fields you name change (`null` removes one).

<Endpoint method="GET" path="/v1/customers?external_id=u_42&limit=20" />

A [page](#conventions) of your customers, newest first. `external_id` finds
the one you know by your own ID.

There is no delete. Forgetting a customer's [profile](/docs/profiles) and
deleting its [secrets](/docs/vault) erases their browser data and stored
details; the customer record itself (name, email, metadata, limits), its
ledger entries and its intent history stay.

### A customer's wallet

<Endpoint method="POST" path="/v1/customers/{id}/wallet_links" />

A link where the customer sets up their [wallet](/docs/wallets) ahead of
their first purchase: `amount`, `return_url` and `ttl_seconds` are optional.
Returns a `wallet_link` with HTTP `201`; send its `url` to the customer alone.
It takes no `Idempotency-Key`.

<Endpoint method="GET" path="/v1/customers/{id}/wallet" />

Where the customer's wallet stands (`status`) and the one step left
(`next_step`), checked against a purchase of `?amount=` cents if you give one.
Never their balance or limits. `503 wallet_unavailable` when wallets cannot be
read right now. Both are described in
[wallet links](/docs/wallets#wallet-links).

## Create an intent

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

| Parameter | Type | |
| --- | --- | --- |
| `intent` | string | **Required.** What to buy or book, in natural language. |
| `max_amount` | integer | Spending cap in cents. The intent fails rather than exceed it. |
| `currency` | string | ISO currency code. Defaults to the wallet's currency. |
| `customer` | string | The [customer](#customers) to buy for: their [wallet](/docs/wallets) pays and their browser profile is used. Without it, the account. |
| `country` | string | Where the buyer's browser goes out from, as a lowercase ISO 3166-1 alpha-2 code (`gb`, not `uk`). Without it: the country the payer last browsed from, else the country of `end_user_ip`, else the default. `422 country_unavailable` when no browser can go out from it. |
| `end_user_ip` | string | Your user's own IP address, so their country can be inferred. Your server's address says nothing of where they are. Not stored. |
| `context` | object | Structured details: addresses, traveler names, loyalty numbers. |
| `require_payment_link` | boolean | Always pause at checkout with a payment link (`reason: requested`) for the payer to approve, instead of paying on its own. |
| `sign_in_agent` | boolean | When the shop wants your customer signed in with a phone number or email and a code, the agent signs them in, [asking you for each](/docs/intents#signing-in-by-message) (`true`, the default), or never asks (`false`). |
| `pay_at_shop` | boolean | Your customer pays the shop themselves: at the payment step the intent asks them to [scan the shop's UPI QR code](/docs/intents#paying-at-the-shop), and no wallet is used (the fee is waived for now). Live keys only. |
| `webhook_url` | string | An `https` URL that gets this intent's [webhook events](/docs/webhooks#one-intent-or-session), signed with a `webhook_secret` returned once in the create response. |
| `sign_in_hint` | object | `{ "phone" }` or `{ "email" }`: your customer's, so the agent need not ask. Sealed, used once. |
| `secrets` | array | Handles (`sec_…`) of the payer's [secrets](/docs/vault) 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. |
| `input_ttl_seconds` | integer | How long a question the agent asks your customer (a size, an email for the order) may wait for an answer, from 60 to 86400 (a day, the default). If nobody answers within two minutes the question is parked and the intent stays `requires_input` until it is answered, then carries on. Unanswered by then, it fails with `input_expired`. Sign-in codes are not parked. |
| `sign_in_link` | boolean | When the shop wants your customer signed in and the agent can't do it by message, wait with a [sign-in link](/docs/intents#sign-in-links) (`true`, the default) or fail with `login_required` (`false`). |
| `metadata` | object | Up to 20 of your own key/value pairs. |

```bash
curl https://api.openintents.io/v1/intents \
  -H "Authorization: Bearer $OPENINTENTS_API_KEY" \
  -H "Idempotency-Key: 5b1f6c1e-coffee-0845" \
  -H "Content-Type: application/json" \
  -d '{
    "intent": "flat white, oat milk, pickup 8:45 at the Blue Bottle on 5th",
    "max_amount": 1000,
    "metadata": { "user": "u_42" }
  }'
```

```ts
const res = await fetch("https://api.openintents.io/v1/intents", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.OPENINTENTS_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({ intent: "book an Uber to SFO at 7am", max_amount: 6000 }),
});
const intent = await res.json(); // { id: "int_…", status: "queued", … }
```

```python
import os, uuid, requests

intent = requests.post(
    "https://api.openintents.io/v1/intents",
    headers={
        "Authorization": f"Bearer {os.environ['OPENINTENTS_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={"intent": "reorder my usual coffee beans on Amazon", "max_amount": 3000},
).json()
```

Returns the [intent object](/docs/intents#the-intent-object) with
`"status": "queued"` and HTTP `201`.

## Retrieve an intent

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

Returns the intent. Poll every 2 to 5 seconds, or use [webhooks](/docs/webhooks).

To hold the request open instead, send `Prefer: wait=N` (whole seconds, at most
50; a larger number is applied as 50). The call returns as soon as the intent
is neither `queued` nor `running`, or when the time is up, with a
`Preference-Applied: wait=N` header. The same header works on
`POST /v1/intents`, which then returns the intent as it stands when the wait
ends. A caller who stops waiting loses nothing: the intent runs on.

## List intents

<Endpoint method="GET" path="/v1/intents?status=completed&limit=20" />

Newest first. Filter with `status` and `customer`.

```json
{
  "object": "list",
  "data": [{ "id": "int_7f3a2c", "status": "completed" }],
  "next_cursor": "int_7f29aa"
}
```

## Answer what the agent asked

<Endpoint method="POST" path="/v1/intents/{id}/input" />

For an intent in `requires_input`: `input` (the `input_request.id`) and
exactly one of `value` (the phone number, email, code, or the answer to a
`detail`, one of its `options` when it has any), `decline: true` or
`done: true` (for an `approval`). Returns the intent, `running` again.
A value that doesn't fit the kind is `400`. A question already answered,
expired, or one the intent moved past is `409` with code
`input_not_waiting`. See [signing in by message](/docs/intents#signing-in-by-message).

## Cancel an intent

<Endpoint method="POST" path="/v1/intents/{id}/cancel" />

Cancels an intent that has not paid yet (`queued`, `running`,
`requires_input`, `requires_payment` or `requires_login`). Cancelled intents
are free.

## Sign-in links

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

A link you make up front for a customer to sign in to a shop, with no intent
waiting on it ("connect your account"). The payer opens it, signs in on a
picture of the shop's page, and the shop's cookies are saved with their
[profile](/docs/profiles), so later intents find them signed in. (An intent
that needs a sign-in makes its own link; see [sign-in links](/docs/intents#sign-in-links).)

| Parameter | Type | |
| --- | --- | --- |
| `shop_url` | string | **Required.** The shop's sign-in or home page. |
| `customer` | string | The [customer](#customers) who signs in. Without it, the account. |
| `country` | string | Where the browser goes out from, as for [an intent](#create-an-intent). |
| `end_user_ip` | string | Your user's IP address, to infer the country. Not stored. |
| `return_url` | string | An `https` page the payer is offered a link back to when done. |

Returns HTTP `201` and a `sign_in` object: `id` (`sgn_…`), `status` (`open`,
`active`, `done`, `declined`, `expired` or `cancelled`), `shop`, `customer`,
`country`, `return_url`, `url` (send it to the payer alone), `expires_at` and
`ended_at`. `403 shop_not_allowed` for a shop we do not sign payers in to;
`503 sign_ins_unavailable` when sign-in links are not on for your key.

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

Returns the link. When it ends, an endpoint that names `sign_in.completed` or
`sign_in.expired` is sent a [webhook](/docs/webhooks).

## Portal sessions

<Endpoint method="POST" path="/v1/customers/{id}/portal_sessions" />

Gives one of your [customers](#customers) a page of their own, the payer
portal, where they see which shops they are signed in to, connect more, and
forget what is kept. It acts for that customer only. The portal is on for your
key only when we have turned it on (`503 portal_unavailable` otherwise).

| Parameter | Type | |
| --- | --- | --- |
| `return_url` | string | An `https` page the portal links back to. |
| `country` | string | Where their browser goes out from, as for [an intent](#create-an-intent). |
| `end_user_ip` | string | Their IP address, to infer the country. Not stored. |
| `ttl_seconds` | integer | How long the portal stays open once they are in: 60 to 86400, default 28800 (8 hours). |

Returns HTTP `201` with `{ "id", "object": "portal_session", "customer", "url",
"expires_at" }`. `url` works once and only for 30 minutes: send it to your
customer, who is then signed in to the portal by a cookie for `ttl_seconds`.
It takes no `Idempotency-Key`: each call makes a new link.

## Live views

<Endpoint method="POST" path="/v1/sessions/{id}/live_links" />

Returns HTTP `201` with `{ "object": "live_link", "session", "url",
"expires_at" }`: a page that shows a [session's](/docs/sessions) browser live
and lets whoever opens it tap and type, for 10 minutes. Each call makes a new
link and takes no `Idempotency-Key`. `409 session_not_open` for a session that
is not open, `404` for another account's session, and `503 live_unavailable`
when live views are not on for the session.

## Webhook endpoints

`/v1/webhook_endpoints` lists, creates, updates and deletes your account's
endpoints, with `rotate_secret`, `test` and a delivery log with `redeliver`
under each. They are covered in [Webhooks](/docs/webhooks#managing-endpoints).

## Profiles, sessions and timelines

- [Browser profiles](/docs/profiles): `/v1/profiles`, the logins and carts
  each payer keeps.
- [Browser sessions](/docs/sessions): `/v1/sessions`, a payer's browser driven
  step by step.
- [Secrets](/docs/vault): `/v1/secrets`, `/v1/customers/{id}/secret_requests`
  and `/v1/secret_requests/{id}`, the details a payer stores once.
- [Events & live streams](/docs/events): `/v1/intents/{id}/events` and
  `/v1/sessions/{id}/events`.

## Errors

Errors use standard HTTP status codes and a consistent body:

```json
{
  "error": {
    "type": "invalid_request",
    "code": "max_amount_too_low",
    "message": "max_amount must be at least 50 (cents).",
    "param": "max_amount"
  }
}
```

| Status | `type` | When |
| --- | --- | --- |
| `400` | `invalid_request` | Missing or malformed parameters. |
| `401` | `authentication` | Missing, invalid or revoked API key. |
| `402` | `payment` | The payment for an intent was declined. |
| `403` | `authentication` | The key may not do this, for example a sign-in link for a shop we do not support (`shop_not_allowed`). |
| `404` | `not_found` | No such intent, customer or other object in this account. |
| `409` | `conflict` | Action not allowed in the current status, a profile already in use (`profile_busy`), a question no longer waiting (`input_not_waiting`), or an `Idempotency-Key` reused with another body (`idempotency_key_reused`). |
| `422` | `invalid_request` | The request is well formed but cannot be done, for example `country_unavailable` or `unknown_option`. |
| `429` | `rate_limit` | Too many requests; wait the seconds in the `Retry-After` header. |
| `5xx` | `api_error` | Our fault. Safe to retry with the same `Idempotency-Key`. A `503` with a code such as `sign_ins_unavailable` means a feature is switched off for now. |

## Rate limits

Each API key may make 600 requests a minute in live mode and 300 in test mode,
to `/v1` and `/mcp` together. The limit is also the burst: a key that has been
idle can send that many at once, then about one a tenth of a second (live) or
a fifth of a second (test). Every response from a key carries:

| Header | Meaning |
| --- | --- |
| `RateLimit-Limit` | Requests a minute the key may make. |
| `RateLimit-Remaining` | Requests left before it is refused. |
| `RateLimit-Reset` | Seconds until the key has its full allowance again. |

Past the limit the answer is `429` (`rate_limit_exceeded`) with a
`Retry-After` header: the whole seconds to wait before the next request will
pass. Every other `429` carries `Retry-After` too. Back off for that long, and
read `RateLimit-Remaining` to pace a batch instead of waiting for a refusal.

Failures _during_ an intent (sold out, over budget) are not HTTP errors: the
intent ends in `failed` with an `error.code` such as `over_max_amount`,
`limit_exceeded` (the purchase is over a [customer's](#customers) `limits`),
`payment_declined` (the payer's wallet refused the payment; `message` is its
own reason, and nothing was charged), `currency_unsupported` (the shop charges
in a currency wallets don't pay in yet), `out_of_stock`, `merchant_unavailable` or `login_required` (the shop wants
your customer signed in, and they didn't, or you turned
[sign-in links](/docs/intents#sign-in-links) off). `needs_review` means the payment
step began but its outcome is unknown, so the order may have gone through:
the money stays reserved while we check, and the intent is never run again.
Don't place the same order again until we have checked it.
