Browse docs
previewThese docs describe OpenIntents as it is being built. Endpoints and payloads may change before launch.

REST API

A small JSON API over HTTPS.

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

Authentication

Send your API key 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.

Customers

A customer is one of your own users, kept as a payer of its own: each has the wallet their purchases are paid from, browser profiles and secrets, 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".

POST/v1/customers
ParameterType
external_idstringYour own ID for this user, up to 200 characters. Unique in your account: a second customer with the same one is 409.
namestringUp to 200 characters. Filled in for them when they set up their wallet.
emailstringUp to 320 characters. Filled in for them when they set up their wallet.
metadataobjectUp to 20 string pairs, keys 1 to 40 characters, values up to 500.
limitsobject{ "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.
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": "[email protected]" }'
json
{
  "id": "cus_8f2a",
  "object": "customer",
  "external_id": "u_42",
  "name": "Ada Lovelace",
  "email": "[email protected]",
  "metadata": {},
  "limits": { "per_intent": null, "monthly": null },
  "created_at": "2026-10-01T08:12:00Z"
}

Returns the customer with HTTP 201.

GET/v1/customers/{id}

Returns the customer.

PATCH/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).

GET/v1/customers?external_id=u_42&limit=20

A page 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 and deleting its secrets 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

POST/v1/customers/{id}/wallet_links

A link where the customer sets up their wallet 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.

GET/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.

Create an intent

POST/v1/intents
ParameterType
intentstringRequired. What to buy or book, in natural language.
max_amountintegerSpending cap in cents. The intent fails rather than exceed it.
currencystringISO currency code. Defaults to the wallet's currency.
customerstringThe customer to buy for: their wallet pays and their browser profile is used. Without it, the account.
countrystringWhere 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_ipstringYour user's own IP address, so their country can be inferred. Your server's address says nothing of where they are. Not stored.
contextobjectStructured details: addresses, traveler names, loyalty numbers.
require_payment_linkbooleanAlways pause at checkout with a payment link (reason: requested) for the payer to approve, instead of paying on its own.
sign_in_agentbooleanWhen 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 (true, the default), or never asks (false).
pay_at_shopbooleanYour customer pays the shop themselves: at the payment step the intent asks them to scan the shop's UPI QR code, and no wallet is used (the fee is waived for now). Live keys only.
webhook_urlstringAn https URL that gets this intent's webhook events, signed with a webhook_secret returned once in the create response.
sign_in_hintobject{ "phone" } or { "email" }: your customer's, so the agent need not ask. Sealed, used once.
secretsarrayHandles (sec_…) of the payer's secrets the purchase may use. Left out: the payer's defaults. []: none.
shopsarrayDomains you let the purchase fill secrets on; ["*"] allows any shop, for contact and address secrets only.
input_ttl_secondsintegerHow 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_linkbooleanWhen the shop wants your customer signed in and the agent can't do it by message, wait with a sign-in link (true, the default) or fail with login_required (false).
metadataobjectUp 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 with "status": "queued" and HTTP 201.

Retrieve an intent

GET/v1/intents/{id}

Returns the intent. Poll every 2 to 5 seconds, or use 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

GET/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

POST/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.

Cancel an intent

POST/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.

POST/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, so later intents find them signed in. (An intent that needs a sign-in makes its own link; see sign-in links.)

ParameterType
shop_urlstringRequired. The shop's sign-in or home page.
customerstringThe customer who signs in. Without it, the account.
countrystringWhere the browser goes out from, as for an intent.
end_user_ipstringYour user's IP address, to infer the country. Not stored.
return_urlstringAn 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.

GET/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.

Portal sessions

POST/v1/customers/{id}/portal_sessions

Gives one of your 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).

ParameterType
return_urlstringAn https page the portal links back to.
countrystringWhere their browser goes out from, as for an intent.
end_user_ipstringTheir IP address, to infer the country. Not stored.
ttl_secondsintegerHow 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

POST/v1/sessions/{id}/live_links

Returns HTTP 201 with { "object": "live_link", "session", "url", "expires_at" }: a page that shows a session's 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.

Profiles, sessions and timelines

  • Browser profiles: /v1/profiles, the logins and carts each payer keeps.
  • Browser sessions: /v1/sessions, a payer's browser driven step by step.
  • Secrets: /v1/secrets, /v1/customers/{id}/secret_requests and /v1/secret_requests/{id}, the details a payer stores once.
  • Events & live streams: /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"
  }
}
StatustypeWhen
400invalid_requestMissing or malformed parameters.
401authenticationMissing, invalid or revoked API key.
402paymentThe payment for an intent was declined.
403authenticationThe key may not do this, for example a sign-in link for a shop we do not support (shop_not_allowed).
404not_foundNo such intent, customer or other object in this account.
409conflictAction 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).
422invalid_requestThe request is well formed but cannot be done, for example country_unavailable or unknown_option.
429rate_limitToo many requests; wait the seconds in the Retry-After header.
5xxapi_errorOur 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:

HeaderMeaning
RateLimit-LimitRequests a minute the key may make.
RateLimit-RemainingRequests left before it is refused.
RateLimit-ResetSeconds 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 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 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.