# Praecise developer platform
> The reference, guides and agent endpoints of every Praecise product a program can use today. Every page is generated from what the product itself serves; this was read on 2026-11-14.
Every page is also available as Markdown at the same address with .md added.
## Products
- [Rivier](https://platform.praecise.com/rivier.md): Rivier runs a business's inbox, contacts, campaigns, shop, bookings, books, content, files, memory and agents. Every action in the dashboard is an API operation with the same name on REST, GraphQL, MCP, the SDK and the CLI.
- [Skerry](https://platform.praecise.com/skerry.md): Skerry is an API that instructs regulated providers to move money for businesses: verification, custodied stablecoin wallets, bank accounts, payouts, conversion, and card, bank-debit, stablecoin and bank-transfer payments. Skerry holds no funds and no keys, and cannot sign blockchain transactions.
- [Praecise Cloud](https://platform.praecise.com/cloud.md): Praecise Cloud is AI operations infrastructure: apps, agents and models, from hosting to day-to-day operations, concept to production, for one owner up to large scale. Furcate is the open operating system base from Praecise.
- [Praecise Inbox](https://platform.praecise.com/inbox.md): Email for people, agents and machines. A person approves an access key for named mailboxes and permissions; software then reads, searches, files and sends mail through REST, MCP or A2A. Every call is recorded where the mailbox's holders can see it.
- [Praecise account](https://platform.praecise.com/accounts.md): One Praecise account for every product: sign people in with it, confirm an action with a passkey, be told when they sign out, and manage an account's domains and mailboxes.
## Rivier
- [Getting started](https://platform.praecise.com/rivier/guides/getting-started.md): The base address, how to make a key, and a first call with the Authorization header.
- [Authentication and tokens](https://platform.praecise.com/rivier/guides/authentication.md): Project keys, short-lived tokens, OAuth for apps and account tokens, with the scopes each one holds.
- [MCP](https://platform.praecise.com/rivier/guides/mcp.md): Connect an AI agent to Rivier over the Model Context Protocol with one address and a key.
- [GraphQL](https://platform.praecise.com/rivier/guides/graphql.md): One endpoint for reads and writes across areas, with the same keys and scopes as REST.
- [Command line and SDK](https://platform.praecise.com/rivier/guides/cli.md): The rivier command and the TypeScript client, both generated from the same operation list.
- [Events and webhooks](https://platform.praecise.com/rivier/guides/events.md): Read what happened as a stream, or have signed events sent to your own https endpoint.
- [Errors and limits](https://platform.praecise.com/rivier/guides/errors.md): What a refusal looks like, the status codes, rate limits, retries and repeat-safe writes.
- [Search the web](https://platform.praecise.com/rivier/guides/search.md): Passages from the web that match a question, each with the page it came from.
- [Answer a question](https://platform.praecise.com/rivier/guides/answer.md): One answer to a question, with a numbered source for every claim it makes.
- [Research a topic](https://platform.praecise.com/rivier/guides/research.md): A longer run that answers, looks for what is missing, reads more and answers again.
- [Watch a question](https://platform.praecise.com/rivier/guides/monitor.md): Keep a question on watch and be told when its answer changes.
- [AI search discovery](https://platform.praecise.com/rivier/guides/discovery.md): Measure how AI search engines see your site, with one script tag and an audit.
- [Reference](https://platform.praecise.com/rivier/reference.md): 2108 operations in 25 groups
- [OpenAPI document](https://api.rivier.ai/v1/openapi.json): served by the product
- [Agent endpoint (MCP)](https://api.rivier.ai/mcp)
- [Rivier's own llms.txt](https://platform.praecise.com/rivier/llms.txt)
## Skerry
- [Skerry, for agents](https://platform.praecise.com/skerry/guides/agents.md)
- [Reference](https://platform.praecise.com/skerry/reference.md): 255 operations in 27 groups
- [OpenAPI document](https://api.skerry.xyz/openapi.json): served by the product
- [Agent endpoint (MCP)](https://mcp.skerry.xyz/mcp): 227 tools, listed at https://platform.praecise.com/skerry/agents.md
- [Skerry's own llms.txt](https://platform.praecise.com/skerry/llms.txt)
## Praecise Cloud
- [Getting started](https://platform.praecise.com/cloud/guides/getting-started.md): Get an account, open the Cloud section of Praecise Spaces and deploy your first project on Praecise Cloud.
- [Deploying and rolling back](https://platform.praecise.com/cloud/guides/deploying.md): What happens when you deploy on Praecise Cloud, and how an earlier release is served again.
- [Domains](https://platform.praecise.com/cloud/guides/domains.md): Serve a site on your own domain on Praecise Cloud, with a certificate and an address before the domain is attached.
- [Security defaults](https://platform.praecise.com/cloud/guides/security-defaults.md): The defaults Praecise Cloud applies: access per project, settings outside releases, isolated agents and signed updates.
- [Agents and llms.txt](https://platform.praecise.com/cloud/guides/agents.md): How an agent reads Praecise Cloud through llms.txt and markdown pages, and how a Harness app describes itself to other agents.
- [Reference](https://platform.praecise.com/cloud/reference.md): 14 operations in 3 groups
- [OpenAPI document](https://console.praecise.cloud/api/openapi.json): served by the product
- [Agent endpoint (MCP)](https://console.praecise.cloud/mcp)
- [Praecise Cloud's own llms.txt](https://platform.praecise.com/cloud/llms.txt)
## Praecise Inbox
- [Reference](https://platform.praecise.com/inbox/reference.md): 9 operations in 2 groups
- [OpenAPI document](https://email.inbox.praecise.com/api/openapi.json): served by the product
- [Agent endpoint (MCP)](https://email.inbox.praecise.com/mcp): 10 tools, listed at https://platform.praecise.com/inbox/agents.md
- [Praecise Inbox's own llms.txt](https://platform.praecise.com/inbox/llms.txt)
## Praecise account
- [Quickstart: a first call](https://platform.praecise.com/accounts/guides/quickstart.md): Get a service token, call /api/me, read the answer.
- [Tutorial: sign in with the Praecise account](https://platform.praecise.com/accounts/guides/tutorial.md): A product that signs people in through Praecise, step by step, with working code.
- [Signing people in](https://platform.praecise.com/accounts/guides/sign-in.md): The reference for products: the request, the ID token, passkeys, sign-out and logout tokens.
- [Machines, agents and assistants](https://platform.praecise.com/accounts/guides/agents.md): The device grant, agent clients, assistants acting for a person, and APIs that accept these tokens.
- [The shared services and tokens for an API](https://platform.praecise.com/accounts/guides/api.md): Domains, mailboxes, service tokens, and access tokens for an API with a claims hook.
- [Reference](https://platform.praecise.com/accounts/reference.md): 11 operations in 4 groups
- [OpenAPI document](https://accounts.praecise.com/openapi.json): served by the product
- [Sign-in endpoints](https://platform.praecise.com/accounts/sign-in.md): OpenID Connect, from the service's discovery document
- [Praecise account's own llms.txt](https://platform.praecise.com/accounts/llms.txt)
# Rivier: Getting started
The base address, how to make a key, and a first call with the Authorization header.
## Base address
Every route is served from one host. Answers are JSON. Send a body as `application/json`.
```
https://api.rivier.ai
```
## Make a key
Sign in to the app, open Developers and make a key for the project. Choose the scopes it holds; the secret is shown once, so store it where your server keeps secrets. A key belongs to one project and cannot read another.
## Make a first call
Send the key in the `Authorization` header as a bearer credential. This call searches the web and needs the scope `search:read`.
Request:
```
curl -sS https://api.rivier.ai/search \
-H "Authorization: Bearer $RIVIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "query": "opening hours of the national library" }'
```
Response:
```
{
"highlights": [
{
"text": "…",
"score": 0.82,
"source": { "url": "https://example.org/visit" }
}
]
}
```
A missing or wrong key answers 401. A key without the scope answers 403 and names the scope it needs.
## Pin a version
The API has a dated contract. Send `Rivier-Version` to pin it; every answer names the version it was served under. Within one version only additions happen. A breaking change ships as a new date and the earlier one keeps working for at least twelve months. A version the API does not know answers 400 with the list it supports.
Header:
```
Rivier-Version: 2026-11-01
```
## Try it in a test project
A key is either live or test. A test key works in a test project: calls to outside services are recorded and not made, so nothing leaves the test project. `GET /sandbox` says which kind a key is, `POST /sandbox/seed` fills the test project with sample records and `GET /sandbox/deliveries` lists what would have been sent.
## Where to go next
The full list of operations is the OpenAPI 3.1 document at `https://api.rivier.ai/v1/openapi.json`. It needs no key. The reference pages here are made from the same list.
# Rivier: Authentication and tokens
Project keys, short-lived tokens, OAuth for apps and account tokens, with the scopes each one holds.
## Kinds of credential
Every credential is sent the same way, as `Authorization: Bearer`. They differ in what they reach and how long they live.
- **Secret key**: A key for servers. Belongs to one project. Can be limited by scope, address and expiry, and replaced with an overlap.
- **Publishable key**: A key for browsers. Public reads and form-style writes only, and it must name the origins it may be used from.
- **Short-lived token**: Made from a key with POST /keys/tokens for one browser, one agent run or one outside call. Lives 60 to 3,600 seconds (900 by default), holds at most the scopes of the key that made it and cannot manage keys.
- **OAuth**: For apps. Client credentials at POST /oauth/token, where the client id is the key prefix and the client secret is the key. For an app acting for a person, authorisation code with PKCE: register the app, send the person to the consent screen, and the person can take the grant back at any time.
- **Account token**: For the command line and agents that work above one project. Made by a signed-in person with POST /account/tokens. Lists and makes organisations and projects, and reaches nothing inside a project. Lives 90 days unless you choose another length, up to 365.
## Scopes
A scope is `area:level`. The areas are the ones in the API reference, such as `contacts`, `inbox` or `shop`. The levels are `read`, `write` and `admin`, and a higher level includes the ones below it. Each operation lists the scope it needs. A key with no matching scope answers 403.
An account token has its own three scopes: `account:read`, `account:write` (which includes read) and `account:members`, which is held only when the person asks for it and lets the token invite or change members below the admin role.
## Limit a key
When you make a key you can set the scopes, the days until it expires, the addresses it may call from, the origins a publishable key may be used on, its own requests-per-minute limit, and whether it is live or test.
## Replace and revoke
`POST /keys/{id}/rotate` answers a new key while the old one keeps working for an overlap, so you can deploy the new secret without a gap. `DELETE /keys/{id}` revokes a key at once. `GET /keys/events` lists when each key was made, replaced and revoked. Secrets are stored only as a hash, so a lost secret cannot be shown again.
## Make a short-lived token
Your server keeps the key and hands the browser or the agent a token that expires in minutes.
Request:
```
curl -sS https://api.rivier.ai/keys/tokens \
-H "Authorization: Bearer $RIVIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "scopes": ["contacts:read"], "ttlSeconds": 600 }'
```
Response:
```
{
"token": "ipn_st_…",
"token_type": "Bearer",
"expires_in": 600,
"expires_at": "2026-11-10T12:10:00.000Z",
"scopes": ["contacts:read"],
"key_id": "…"
}
```
## What stays with a person
Some actions are kept for a signed-in person whatever a key holds: approving and sending a campaign, changing the plan or billing, and profile and security settings. Others need the admin level, which ordinary read and write keys do not hold: bulk import, export and erasure of contacts, and making or revoking keys. An agent can prepare these and a person approves them in the app.
# Rivier: MCP
Connect an AI agent to Rivier over the Model Context Protocol with one address and a key.
## Connect
The server is at `https://api.rivier.ai/mcp`. It speaks Streamable HTTP and keeps no session, so one address and one key are all an agent needs. Send the key as a bearer credential, the same as REST. An agent that signs in with OAuth finds the issuer at `/.well-known/oauth-protected-resource/mcp`.
Configuration:
```
{
"mcpServers": {
"rivier": {
"type": "http",
"url": "https://api.rivier.ai/mcp",
"headers": { "Authorization": "Bearer ${RIVIER_API_KEY}" }
}
}
}
```
## Tools
The tool list follows the scopes of the key: a read key sees read tools, a write key also sees the writes its scopes allow. Three tools reach every operation in the API reference, so nothing in the app is missing for an agent.
- `rivier_api_operations`: Find an operation by words, area or level. Answers the id, the route, the level and a summary.
- `rivier_api_read`: Run a read operation with its parameters and query.
- `rivier_api_act`: Run a write operation with its parameters, query and body. Takes idempotencyKey so a retried write is not repeated.
- `rivier_key_scopes`: What the calling key holds: project, scopes and how many operations of each level it can run.
- `rivier_overview`: Contacts, campaigns and flows in one call.
- `rivier_contact_brief`: One person: the record, recent events and consent history in one call.
## Resources and prompts
Resources: `rivier://guide`, `rivier://scopes` and `rivier://operations`. Prompts: `weekly_digest`, `contact_follow_up` and `draft_campaign_brief`. None of the prompts sends or approves anything.
## What an agent cannot do
A call goes through the same route as REST with the same key, so scopes, project and rate limits are unchanged. Actions kept for a person, such as approvals, sends and removals at the admin level, are refused with the reason, and the agent can leave them for a person to approve in the app.
## When a call fails
A failed call answers a fixed shape. Branch on `code`, not on the wording. `hint` names the next step, `param` the fields at fault, and `next` the tool and arguments to call instead where there is one. Long answers are cut at a record boundary with a note to narrow the query; nothing is cut without saying so.
Response:
```
{
"ok": false,
"code": "insufficient_scope",
"retryable": false,
"error": "…",
"hint": "…"
}
```
Codes: `invalid_input`, `insufficient_scope`, `not_found`, `conflict`, `rate_limited`, `missing_params`, `unknown_operation`, `wrong_mode`, `reserved_for_person` and `server_error`.
# Rivier: GraphQL
One endpoint for reads and writes across areas, with the same keys and scopes as REST.
## Endpoint
Send queries and mutations to `POST https://api.rivier.ai/graphql` with the same key as REST. A field needs the same scope as the operation behind it; a field the key may not use answers an error with the code `FORBIDDEN` and the scope required.
Request:
```
curl -sS https://api.rivier.ai/graphql \
-H "Authorization: Bearer $RIVIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "query": "{ __schema { queryType { name } } }" }'
```
## Schema
Read the schema by introspection with any key except a publishable one. Field names follow the operations: the read `search.web.search` is `Query.searchWebSearch`, and the write `analytics.monitor.create` is `Mutation.analyticsMonitorCreate`. The OpenAPI document names the field of each operation in `x-rivier-graphql`.
## Limits
One request may go 8 levels deep, ask for 12 root fields and be 32,000 characters long. Each request also has a cost, counted from the fields it asks for, with a ceiling of 600. A request over a limit answers 400 and says which limit. The rate limit of the key counts GraphQL requests too.
## Persisted queries
Send the SHA-256 of a query in `extensions.persistedQuery.sha256Hash` to run a query the server already holds. If it does not hold it, the answer is the error `PERSISTED_QUERY_NOT_FOUND`; send the query text with the hash once and use the hash alone after that.
Body:
```
{
"extensions": {
"persistedQuery": { "sha256Hash": "…" }
}
}
```
## Errors
Errors are in the `errors` list, each with `extensions.code`: `GRAPHQL_VALIDATION_FAILED`, `FORBIDDEN`, `BAD_REQUEST`, `RATE_LIMITED`, `QUOTA_EXCEEDED` or `INTERNAL`.
# Rivier: Command line and SDK
The rivier command and the TypeScript client, both generated from the same operation list.
## Command shape
A command is the words of an operation: area, thing, action. Path and query values are flags. `--body` takes JSON, `@file` or `-` for standard input. `rivier ops` lists every command, and a word after it filters the list. `--help` on any command shows the route it calls and the level it needs.
Shell:
```
rivier ops invoice
rivier contacts contact list
rivier keys key list --help
```
## Environment
- `RIVIER_API_KEY`: The key or token to call with. Also --key.
- `RIVIER_API_URL`: The API address. The default is https://api.rivier.ai. Also --url.
- `RIVIER_ORG_ID`: The organisation for organisation commands, so it need not be repeated. Also --orgId.
## Exit codes
0 is success. 1 is a server or network failure. 2 is a usage error: an unknown command, a missing value or a body that is not JSON. 3 means the API refused the call; the code and its explanation are printed. Reads wait and retry when the rate limit asks them to.
## TypeScript client
The client has one method per operation, named with the same words as the command, and no dependencies. A refusal throws `RivierError` with `status`, `message` and `body`. Reads retry on 429 after the wait the API names.
TypeScript:
```
import { createClient, RivierError } from "@rivier/sdk";
const rivier = createClient({
baseUrl: "https://api.rivier.ai",
apiKey: process.env.RIVIER_API_KEY,
});
try {
const contacts = await rivier.contacts.contact.list();
console.log(contacts);
} catch (err) {
if (err instanceof RivierError) console.error(err.status, err.message);
else throw err;
}
```
## Getting the package
The command and the client ship together as `@rivier/sdk`. The package is not on the public npm registry yet. Until it is, every command has the same REST call beside it in the API reference, and both do exactly the same thing.
# Rivier: Events and webhooks
Read what happened as a stream, or have signed events sent to your own https endpoint.
## Read the event stream
Rivier records an event when something changes: a key is made, a message is delivered, a message bounces. `GET /events` reads events from a cursor, limited to the areas the key can read. `GET /events/stream` is the same as a server-sent stream; reconnect with `Last-Event-ID` to carry on where you stopped. `GET /events/catalogue` lists every event type and the operation that raises it.
Request:
```
curl -sS -N https://api.rivier.ai/events/stream \
-H "Authorization: Bearer $RIVIER_API_KEY"
```
## Register a webhook
`POST /webhooks/endpoints` registers an https address and the event types it takes; a pattern such as `inbox.message.*` takes a whole family. The signing secret is in the answer once. Making and removing endpoints needs `keys:admin`.
Request:
```
curl -sS https://api.rivier.ai/webhooks/endpoints \
-H "Authorization: Bearer $RIVIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/hooks/rivier", "events": ["inbox.message.*"] }'
```
## Check the signature
Each delivery carries `X-Rivier-Signature` with a time and an HMAC-SHA256 of the time, a full stop and the raw body, keyed with the endpoint secret. Compute it over the body exactly as received, before parsing, compare in constant time, and refuse a time more than five minutes old.
Header:
```
X-Rivier-Signature: t=1762776000,v1=5f2b…
```
TypeScript:
```
import { verifyWebhook } from "@rivier/sdk";
verifyWebhook({
secret: process.env.RIVIER_WEBHOOK_SECRET,
rawBody,
signature: request.headers.get("x-rivier-signature"),
});
```
## Deliveries and replay
`GET /webhooks/deliveries` is the log: each attempt, what your endpoint answered and what is waiting to be tried again. An endpoint that keeps failing is switched off; `POST /webhooks/endpoints/{id}/enable` switches it back on, and the replay routes send one delivery or everything from a point in the stream again. Deliveries can arrive more than once, so use the event id to ignore repeats.
# Rivier: Errors and limits
What a refusal looks like, the status codes, rate limits, retries and repeat-safe writes.
## What a refusal looks like
A refusal is JSON with `error` and, in most cases, `message` in plain words. A validation error also lists `details.issues`, each with the path of the field and what is wrong with it.
## Status codes
- `400`: The request is malformed or a value is not allowed. Fix the request before sending it again.
- `401`: No valid credential. The key is missing, wrong, expired or revoked.
- `403`: The credential lacks the scope, or is used from an origin or address it is not allowed on, or the action is kept for a person.
- `404`: No such record in this project. A record in another project also answers 404.
- `409`: The request conflicts with the current state of the record.
- `429`: Too many requests. Wait the number of seconds in the answer and send it again.
- `5xx`: Something failed on our side. A read can be sent again; send a write again only with the same Idempotency-Key.
## Rate limits
Each key has a requests-per-minute limit, set by the plan or lower when the key is made. Past it the answer is 429 with a `Retry-After` header and `retry_after_seconds` in the body. Several processes sharing one key share its limit, so keep parallel work on one key small.
Response:
```
HTTP/1.1 429
retry-after: 12
{
"error": "rate limit exceeded",
"code": "rate_limited",
"limit_class": "…",
"retry_after_seconds": 12,
"message": "…"
}
```
## Repeat-safe writes
Send an `Idempotency-Key` header with a write, with a value of your own such as an order number. If the same request arrives twice with the same key, for example after a timeout, it is carried out once. Through MCP the same value is the `idempotencyKey` argument.
Header:
```
Idempotency-Key: order-10482
```
## Versions
An unknown `Rivier-Version` answers 400 with the error `unsupported_version` and the versions the API supports.
Response:
```
{
"error": "unsupported_version",
"message": "…",
"supported": ["2026-11-01"]
}
```
# Rivier: Search the web
Passages from the web that match a question, each with the page it came from.
`POST /search`
Search the web index for a question. The answer is a list of passages, each with a score and the page it came from, ready for an agent to quote. Needs the scope `search:read`.
| Field | Type | Meaning |
| --- | --- | --- |
| `query` | string | Required. The question, 1 to 1,000 characters. |
| `topK` | number | Optional. How many passages to return, 1 to 50. |
Request:
```
curl -sS https://api.rivier.ai/search \
-H "Authorization: Bearer $RIVIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "query": "opening hours of the national library" }'
```
Response:
```
{
"highlights": [
{
"text": "…",
"score": 0.82,
"source": { "url": "https://example.org/visit" }
}
]
}
```
The same operation is the command `rivier search web search` and the MCP tool `rivier_search`.
# Rivier: Answer a question
One answer to a question, with a numbered source for every claim it makes.
`POST /answer`
Ask a question and get one answer written from what the search found. Every claim carries a numbered marker that points to its source. Needs the scope `search:read`.
| Field | Type | Meaning |
| --- | --- | --- |
| `query` | string | Required. The question, 1 to 1,000 characters. |
| `topK` | number | Optional. How many passages to write the answer from, 1 to 20. |
| `forceEscalate` | boolean | Optional. Use the larger model for this answer from the start. |
| `stream` | boolean | Optional. Send the answer as server-sent events while it is written. |
Request:
```
curl -sS https://api.rivier.ai/answer \
-H "Authorization: Bearer $RIVIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "query": "opening hours of the national library" }'
```
Response:
```
{
"answer": "The library opens at 09:00 on weekdays [1] and at 10:00 on Saturdays [2].",
"citations": [
{ "marker": 1, "url": "https://example.org/visit", "text": "…" },
{ "marker": 2, "url": "https://example.org/hours", "text": "…" }
],
"claims": [
{
"text": "The library opens at 09:00 on weekdays.",
"verdict": "supported",
"quotes": ["…"],
"grounded": true
}
],
"confidence": 0.84,
"sufficiency": "supported",
"conflicts": []
}
```
`claims` lists each statement with the quotes that support it. `confidence` is between 0 and 1. `sufficiency` says whether the sources were enough, and when they were not the answer says so and does not guess. `conflicts` lists sources that disagree.
The same operation is the command `rivier search web answer` and the MCP tool `rivier_answer`.
# Rivier: Research a topic
A longer run that answers, looks for what is missing, reads more and answers again.
`POST /research`
Research is a run, not a single call. The request answers at once with 202 and a run id. The run then answers the question, finds the claims it could not support, reads more pages for them and answers again, up to three rounds. Needs the scope `search:read`.
| Field | Type | Meaning |
| --- | --- | --- |
| `query` | string | Required. The research question, 1 to 1,000 characters. |
| `webhookUrl` | string | Optional. An https address that is sent the final state when the run ends. |
| `maxIterations` | number | Optional. How many rounds to run, 1 to 3. The default is 3. |
Request:
```
curl -sS https://api.rivier.ai/research \
-H "Authorization: Bearer $RIVIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "query": "opening hours of the national library" }'
```
Response:
```
{
"run_id": "run_5c1f…",
"status": "queued"
}
```
## Follow a run
`GET /research/{id}` answers the state and, when the run has ended, the result. `GET /research/{id}/events` is the progress as a server-sent stream; reconnect with `Last-Event-ID`. `POST /research/{id}/cancel` stops the run. `GET /research/budget` says what research may still spend this period.
The result has the same shape as an [answer](https://rivier.ai/docs/answer.md), with the number of rounds that ran.
# Rivier: Watch a question
Keep a question on watch and be told when its answer changes.
`POST /monitor`
Watch a question and be told when its answer changes. Rivier asks the question again on a schedule and compares the claims in the new answer with the last one. Making a watch needs the scope `analytics:write`; reading needs `analytics:read`.
| Field | Type | Meaning |
| --- | --- | --- |
| `query` | string | Required. The question to watch, 1 to 1,000 characters. |
| `webhookUrl` | string | Optional. An https address that is sent the change and the new answer. |
Request:
```
curl -sS https://api.rivier.ai/monitor \
-H "Authorization: Bearer $RIVIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "query": "opening hours of the national library", "webhookUrl": "https://example.com/hooks/watch" }'
```
Response:
```
{
"monitor_id": "…"
}
```
## Routes
- `POST /monitor`: Watch a question. Answers 201 with the id of the watch.
- `GET /monitor`: The watched questions.
- `GET /monitor/{id}`: One watched question with its last answer.
- `DELETE /monitor/{id}`: Stop watching.
## What a change looks like
When the answer changes, the address you gave is sent the claims that were added and removed, the change in confidence and the new answer with its sources.
Body:
```
{
"monitor_id": "…",
"diff": {
"addedClaims": ["…"],
"removedClaims": [],
"confidenceDelta": -0.12
},
"answer": "…",
"citations": [],
"confidence": 0.71
}
```
# Rivier: AI search discovery
Measure how AI search engines see your site, with one script tag and an audit.
AI search engines answer a question by reading pages and quoting them. Discovery shows whether those engines can read your site, which of their crawlers visit it, and what to change so that your pages can be quoted. The routes are in the [Analytics and discovery](https://rivier.ai/docs/reference/analytics.md) reference.
## Add the tag
One script in the head of each page reports what the page offers a crawler: whether structured data is present, whether the content is in the HTML the server sends, and the page metadata. It sets no cookie. It works in any framework and in site builders that let you add code to the head.
HTML:
```
```
## Audit a page
`POST /geo/audit` scores a page on the four things that decide whether an engine can read and quote it. Needs the scope `analytics:write`.
Request:
```
curl -sS https://api.rivier.ai/geo/audit \
-H "Authorization: Bearer $RIVIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/" }'
```
- Valid schema.org JSON-LD, such as Organization, Product or FAQPage, so that names and facts are stated and not guessed from prose.
- The content is in the HTML the server sends, so crawlers that do not run scripts can read it.
- robots.txt lets the crawlers in that you want to be quoted by. A blocked crawler cannot quote you.
- Pages lead with the answer, name things clearly and show when they were last updated.
## What else you can read
`GET /geo/telemetry` lists AI crawler visits to a site. `GET /geo/tracker` shows the prompts you track and where you stand in the answers. `GET /geo/recommendations` lists what to change to be quoted more. `POST /geo/simulate` shows how an answer engine would see a page.
# Skerry: Skerry, for agents
> Skerry is an API that instructs regulated providers to move money for businesses: verification, custodied stablecoin wallets, bank accounts, payouts, conversion, and card, bank-debit, stablecoin and bank-transfer payments. Skerry holds no funds and no keys, and cannot sign blockchain transactions.
Base URL: https://api.skerry.xyz
- [OpenAPI document](https://api.skerry.xyz/openapi.json)
- [Capabilities and the payment-method matrix](https://api.skerry.xyz/capabilities) (needs a key)
- [Index of operations](https://api.skerry.xyz/)
## Authentication
Every call but `/` sends `Authorization: Bearer sk_test_…` or `sk_live_…`. The environment is in the key, not in a header, so the same code runs against sandbox and live by swapping one value. Where the deployment is configured with an authorisation server, a short-lived access token issued for this API is accepted in place of a key; `/.well-known/oauth-protected-resource` says whether and from whom.
Agent keys: Register agents with `POST /agents` and give each its own key with `POST /agents/{id}/keys`: that key is the agent, and it can only move money under a spend policy. On a shared key, send `Skerry-Agent: ` so the agent's policies apply and the audit trail names it; where a policy is bound to an agent, a money-moving call that names no agent is refused with `agent_required`. Once any agent is registered the header must name a registered, active one.
Signed requests: An agent may sign requests with a registered Ed25519 key using HTTP Message Signatures (RFC 9421): `Signature-Input` and `Signature`, covering `@method`, `@target-uri` and, when there is a body, `content-digest` (RFC 9530), with `created`, `expires` (at most 300 seconds later), `keyid` (the key's JWK thumbprint) and `tag="skerry-agent"`. The API key still identifies the account; a valid signature proves which agent sent the request.
OAuth: where the deployment accepts access tokens, `https://api.skerry.xyz/.well-known/oauth-protected-resource` names the authorisation server. It answers 404 when tokens are not accepted.
## Environments
A `sk_test_` key reaches only sandbox data and test money; a `sk_live_` key only live. Nothing is shared between them, ids from one are not found in the other, and sandbox-only helpers refuse a live key.
## The order of calls
### Find out what you may do
1. `GET /whoami`: the account, environment and scopes behind your key
2. `GET /capabilities`: what the platform offers and what is switched on, with the payment-method matrix
3. `GET /customers/{id}/payment-methods`: what one business can be paid with and pay with right now
### Bring a business on
1. `POST /customers`: record the business
2. `POST /customers/{id}/onboarding-link`: a link its owner opens to finish verification on hosted pages
3. `GET /customers/{id}`: read `can` and `requirements` until what you need is enabled
### Take a payment
1. `POST /checkout-sessions`: start a checkout; send the payer to `url`, or use `payment_instructions` for stablecoin and bank transfer
2. `GET /checkout-sessions/{id}`: where the session stands
3. `GET /payments/{id}`: the payment, once there is one; build orders from the `payment.succeeded` event
### Pay somebody
1. `POST /customers/{id}/payees`: add where the money goes
2. `POST /payouts/quote`: what it would cost and which rail it would leave on
3. `POST /payouts`: send it, with an Idempotency-Key
4. `GET /payouts/{id}`: follow it to `paid`
### Convert a balance
1. `POST /conversions/quote`: a priced offer, valid for a short time and usable once
2. `POST /conversions`: execute the quote, with an Idempotency-Key
3. `GET /conversions/{id}`: whether it has settled
### Refund a payment
1. `POST /payments/{id}/refunds`: all or part, with an Idempotency-Key
2. `GET /payments/{id}/refunds`: the refunds made against it
### Charge for a resource by the call
1. `POST /customers/{id}/priced-resources`: say what a call costs
2. `POST /machine-payments/challenge`: what to answer an unpaid request with: status 402 and its headers
3. `POST /machine-payments/verify`: check and settle the payment a client sent; returns the receipt headers
### Let agents buy from a business
1. `POST /customers/{id}/agentic/pricing-callback`: where line items, totals, tax and fulfilment are asked for
2. `POST /customers/{id}/agentic/credentials`: a bearer token for the agent platform
3. `POST /agentic/{customer_id}/checkout_sessions`: the agent platform's first call, under that token
### Let platforms buy from a business through the Universal Commerce Protocol
1. `POST /customers/{id}/agentic/pricing-callback`: where line items, totals and tax are asked for; the same one agentic checkout uses
2. `GET /customers/{id}/ucp/settings`: what the business has set; a PUT to the same address saves the platforms it accepts and what its profile publishes
3. `GET /ucp/{customer_id}/.well-known/ucp`: the profile the business points its own /.well-known/ucp at
4. `POST /ucp/{customer_id}/checkout-sessions`: the platform's first call, signed with a key its own profile publishes
### Wait for a person
1. `GET /approvals/{id}`: read a held operation until it is `executed`, `denied` or `expired`
2. `GET /events`: or read the `approval.decided` event
## Quote, then commit
Where the amount matters, ask first and act second. A quote changes nothing; the second call is the one that moves money.
- `POST /payouts/quote`, then `POST /payouts`: What a payout would cost, as a quote that binds the instruction. The answer carries `quote_id` and `valid_until` (120 seconds by default); `POST /payouts` with that `quote_id` executes exactly the quoted customer, amount, source, destination and rail, once. For a bank payout it says which rail it would leave on and why (`rail`, `rail_reason`) and carries the name check on the payee (`payee_check`). `fee`, `rate` and the receive amount are what the provider's dry run reported when the quote was made; the provider is not asked to hold them, so this is not a guaranteed rate. `amount` is a decimal string and `amount_minor` the same in integer minor units of the source currency; send either. `fee_minor` is in minor units of `fee_currency`, `receive_amount_minor` of `receive_currency`.
- `POST /conversions/quote`, then `POST /conversions`: A priced offer to convert, valid for a short time and usable once.
## Dry run
Send `Skerry-Dry-Run: true` (or `dry_run: true` in the body) on a money-moving call to check it without doing it: payouts, batches, conversions, refunds, off-session charges, settlements, subscriptions, checkout sessions and payment links. Validation, ownership, the freeze, policies, budgets, the customer's risk rules and payee rules are evaluated, and for payouts and conversions the provider's own dry run is asked. The answer is 200 with `dry_run: true`, `next` (run, hold or refuse), `checks` (each check and its result), `object` (what would be created, where the operation can state it) and, for hold or refuse, why. Nothing is reserved, sent or created. With the header, no Idempotency-Key is needed and no idempotency record is kept.
## Idempotency
Send `Idempotency-Key` on every POST. A repeat returns the first response rather than acting twice; a repeat with a different body is refused. This is how a retry after a timeout stays safe. Calls that move money require it, `POST /payouts` included.
## Approvals
A money-moving call can answer HTTP 202 with `status: "requires_approval"` and an `approval_id`. Not an error: the operation is held for a person. The body names the approval.
Nothing has moved. Do not retry the call. Read `GET /approvals/{id}` until its status is `executed`, `denied` or `expired`; the result of an executed operation is on the approval. The key that asked cannot approve.
## Amounts
Every amount is one of two forms, and the reference says which. Integer minor units: a whole number of the currency's smallest unit (cents for USD and EUR, six places for a stablecoin) beside a lowercase `currency`. Payments, refunds, settlements, conversions, ledger entries, budgets and every field named `*_minor` use this. Decimal strings: `"12.50"`, used by `amount` and `fee` on payouts and by wallet balances, which predate the rule. Wherever a decimal string is returned, the same amount is returned beside it as `amount_minor` (or `fee_minor`) with `currency`; `POST /payouts` and `POST /payouts/quote` accept `amount_minor` in place of `amount`. A rate is a ratio, not an amount, and stays a decimal string. Limits on a spend policy are integer minor units of the unit its `scope` names.
## Pagination
List endpoints return newest first and take `?limit=` and `?cursor=`. A response carrying `next_cursor` has more.
## Rate limits
Reads are limited to 600 and writes to 120 per minute per key. Past that, 429 `rate_limited` with `Retry-After`. Every response carries `RateLimit-Limit` and `RateLimit-Remaining`.
## References
Send `external_ref` (up to 200 characters) with `external_kind` (invoice, bill, order, booking or other) on a checkout session, payment link, payment, refund, payout, conversion or subscription to name the document it belongs to in your own system. Skerry stores it, returns it on the object, on every event about it and on the ledger entry, and lists by it (`?external_ref=`). On a bank payout it is also what the payee's bank shows, cut to what the rail carries.
## Receipts
Every movement has a receipt at `GET /receipts/{id}`, and the `movement.pending`, `movement.posted` and `movement.reversed` events carry it. A receipt is signed with Ed25519 over its canonical JSON (keys sorted, no whitespace) without the `signature` field; verify it with the key named by `signature.key_id` at `/.well-known/skerry-receipt-keys`.
## Errors
Failures are `{ error: { code, message, detail, request_id } }`. Branch on `code`, never on `message` — messages are written for people and will change.
| Status | Code | What it means and what to do next |
|---|---|---|
| 400 | `invalid_request` | The body is wrong. `detail.field` names which part. |
| 401 | `unauthenticated` | No key, or not a key we issued. Send it as `Authorization: Bearer sk_…`. |
| 401 | `key_revoked` | The key was revoked. Issue another. |
| 401 | `key_expired` | The key's `expires_at` has passed. Issue another, or roll a key before it expires. |
| 401 | `signature_invalid` | The request's HTTP Message Signature could not be accepted. `detail.reason` says why: malformed, expired, digest_mismatch, unknown_key, bad_signature and others. |
| 401 | `signature_required` | This agent must sign its requests and this one was not signed. |
| 401 | `token_invalid` | The access token was not accepted. `detail.reason` says why: wrong_audience, wrong_issuer, bad_signature, missing_claim and others. |
| 401 | `token_expired` | The access token has expired. Get another from the authorisation server. |
| 401 | `dpop_invalid` | The token is bound to a key and the DPoP proof was missing, wrong for this request, or already used. |
| 503 | `oauth_unavailable` | The authorisation server's keys could not be read, so tokens cannot be checked. API keys still work. |
| 403 | `agent_required` | Say which agent is acting: send `Skerry-Agent` or use a key bound to the agent. Policies on this account are bound to agents. |
| 403 | `agent_mismatch` | `Skerry-Agent`, the key, the signature or the token name different agents. |
| 403 | `agent_unknown` | No such agent is registered in this environment. Once any agent is registered, `Skerry-Agent` must name one. |
| 403 | `agent_suspended` | The agent, or an agent it was delegated from, is suspended. |
| 403 | `agent_revoked` | The agent, or an agent it was delegated from, has been revoked. Also 409 when changing a revoked agent. |
| 403 | `agent_expired` | The agent's delegation has expired. |
| 403 | `agent_not_permitted` | An agent's own credential may not do this: it would widen or restore authority, or act for another agent. |
| 403 | `scope_not_held` | A key cannot grant, or take over, scopes it does not hold itself. `detail.missing` lists them. |
| 403 | `delegation_not_narrower` | A delegation asked for more than the delegating agent has. `detail.field` names which part. |
| 403 | `customer_frozen` | Money movement is frozen for this customer. `POST /customers/{id}/unfreeze` lifts it. |
| 403 | `partner_frozen` | Money movement is frozen for the whole account in this environment. `POST /controls/unfreeze` lifts it. |
| 402 | `not_in_plan` | The plan does not include this. Usage on the project page says where you stand. |
| 403 | `insufficient_scope` | The key is narrower than the call. Each operation names its scope. |
| 403 | `live_not_approved` | A live key was used before live was approved. Sandbox still works. |
| 403 | `partner_suspended` | The account is suspended. Nothing will succeed until that is lifted. |
| 404 | `not_found` | No such object in this project — including one that belongs to somebody else. |
| 409 | `already_exists` | The object is already there. Usually a retry without an Idempotency-Key. |
| 409 | `request_in_flight` | The same Idempotency-Key is still running. Wait and read the first answer. |
| 400 | `idempotency_key_required` | This call moves money; send an Idempotency-Key so a retry cannot act twice. `POST /payouts` requires it too. |
| 402 | `payment_failed` | The payer's bank or card refused. `detail.reason` says why in plain terms. |
| 403 | `limit_exceeded` | A spend policy on this key refuses the operation. `detail.limit` is per_payment, daily, monthly or count; `detail.unit` names the currency the amounts are minor units of. |
| 403 | `operation_not_allowed` | A spend policy on this key does not allow that operation. `detail.allowed` lists what it allows. |
| 403 | `currency_not_allowed` | A spend policy on this key does not allow that currency. |
| 403 | `customer_not_allowed` | A spend policy on this key is for a different customer. |
| 403 | `merchant_not_allowed` | The merchant is not on the mandate's allow-list. |
| 403 | `wallet_not_allowed` | The mandate does not cover the wallet the money would leave. |
| 403 | `mandate_not_yet_valid` | The mandate's `valid_from` has not come yet. |
| 403 | `consent_required` | The mandate is marked `consent_required` and has no verified consent evidence for its rule as it stands. Record it with `POST /spend-policies/{id}/consent`. |
| 403 | `agent_cannot_approve` | An agent cannot decide an approval or make approval links. |
| 403 | `approval_evidence_required` | The approval needs passkey evidence from the approver, and none was sent or it could not be verified. |
| 409 | `approver_not_distinct` | This key, member or approver has already decided the approval. A second approval must come from someone else. |
| 409 | `approval_decided` | The approval has already been decided. |
| 409 | `approval_expired` | The approval was not decided in time. The operation did not run. |
| 409 | `approval_not_usable` | `Skerry-Approval` did not name an approval that was approved for exactly this request, asked by this key, and not used yet. |
| 403 | `payee_not_allowed` | The destination is not on this key's allow-list. |
| 403 | `mandate_expired` | The spend policy on this key has expired. |
| 403 | `policy_revoked` | Every spend policy on this key was revoked. |
| 403 | `policy_required` | This key may only act under a spend policy and none is attached. |
| 403 | `self_approval` | The key that asked for an operation cannot approve it. |
| 409 | `onboarding_incomplete` | The customer has not finished the hosted onboarding this needs. `detail.next_step` says which. |
| 409 | `mandate_required` | The payer has not agreed to be charged later. |
| 409 | `refund_not_possible` | The refund is more than what remains of the payment. |
| 409 | `quote_expired` | The conversion or payout quote's time is up. Ask for a new one. |
| 409 | `quote_moved` | The market moved beyond the quote's tolerance. Ask for a new one. |
| 409 | `quote_used` | That conversion or payout quote has already been used. |
| 202 | `requires_approval` | Not an error: the operation is held for a person. The body names the approval. |
| 429 | `rate_limited` | Too many requests on this key. `Retry-After` says when to try again. |
| 409 | `books_not_connected` | The customer's books are not connected, so there is nothing to hand over to. `detail.next_step` says how to connect them. |
| 409 | `capability_unavailable` | Not available for this project. `/capabilities` says what is. |
| 409 | `capability_coming_soon` | Built, not yet switched on for this project. |
| 403 | `reliance_not_enabled` | `reliance` was sent by an account that is not approved to verify its own customers. |
| 400 | `refund_destination_required` | Where the money came from is not on record for this deposit. Send `destination` with the refund: an address and chain, or a payee. |
| 409 | `refund_not_supported` | Payments by this method cannot be refunded through the processor. Return the money another way. |
| 409 | `deposit_resolved` | The unmatched deposit has already been assigned or sent back. Read it to see which. |
| 409 | `currency_mismatch` | The amount is in another currency than the object it is applied to. `detail.currency` says which is expected. |
| 400 | `price_below_minimum` | The price is below the smallest single payment for a method (`detail.method`, `detail.minimum`), or has a part of a cent. Set `credit_only`, raise the price, or drop the method. |
| 409 | `credit_required` | The resource's price is too small for a single payment. Buy credit with `POST /machine-payments/credits` and pay each call from it. |
| 409 | `dispute_without_payment` | The payment behind the dispute is not on record, so no evidence can be drafted from it. |
| 502 | `upstream_unavailable` | On the pay-by-the-call proxy: the payment was taken and the business's address could not be reached. The answer carries the receipt; the business can refund the payment. |
| 409 | `resource_disabled` | The priced resource has been disabled. Register it again to charge for it. |
| 409 | `dispute_closed` | The dispute is no longer waiting for a response; evidence and files are refused. |
| 409 | `meter_inactive` | The meter is no longer active. |
| 403 | `outside_budget` | The mandate or agent has a budget and may only spend from the wallet and currency that budget is in. `detail.budgets` lists them. |
| 403 | `budget_exceeded` | The amount is more than what remains of the budget. `detail.remaining_minor` is in minor units of `detail.currency`. |
| 403 | `funds_earmarked` | Part of the wallet is set aside in a budget and the amount is more than what is free. `detail.available_minor` and `detail.earmarked_minor` are in minor units of `detail.currency`. |
| 409 | `insufficient_funds` | The wallet does not hold that much that is not already set aside. `detail.available_minor` says what is free. |
| 409 | `budget_released` | The budget has been released and can no longer be topped up or spent from. |
| 403 | `destination_not_allowed` | The destination is not on the customer's withdrawal allow-list. |
| 403 | `payee_cooling_off` | The destination was trusted recently and can only be paid small amounts until `detail.until`. `detail.max_minor` is in cents of `detail.unit`. |
| 403 | `risk_refused` | A risk rule on the customer refuses the operation. `detail.rules` says which and why. |
| 409 | `sweep_rule_limit` | The customer already has as many active sweep rules as it may have. |
| 409 | `approver_limit` | The customer already has as many approvers as it may have. |
| 403 | `sandbox_only` | A sandbox helper was called with a live key. |
| 409 | `quote_mismatch` | The payout does not repeat the instruction its quote priced. `detail.field` names what differs: amount, from, to or rail. |
| 429 | `too_many_waiters` | This key already has as many requests waiting (`?wait=`) as it may. Let one finish, or read without wait. |
| 409 | `payout_not_amendable` | The payout's funds have arrived, or it has moved on further, so its amount can no longer be changed. `detail.status` says where it is. |
| 409 | `bank_link_finished` | That bank login has already been finished. Start another with `POST /customers/{id}/bank-link`. |
| 409 | `bank_link_expired` | The link token expired before the bank login was finished. Start another with `POST /customers/{id}/bank-link`. |
| 409 | `schedule_not_cancelable` | The scheduled payout has already run, failed or been cancelled, or is running now. |
| 409 | `schedule_not_waiting` | Only a conditional payout that is still waiting can be released. |
| 409 | `schedule_not_due` | Only a payout that is scheduled can be advanced in the sandbox; a waiting one is released. |
| 409 | `schedule_expired` | The conditional payout lapsed before it was released, and was cancelled. |
| 403 | `agent_payment_over_max` | The merchant asks for more than `max_amount`. Nothing was signed. `detail.asked_minor` is the lowest price asked, in US cents. |
| 409 | `no_payable_offer` | The merchant asked for payment in no way the agent can pay. `detail.skipped` says why each offer was passed over: another network or asset, a transfer method that is not signed, no wallet, no payment method. |
| 409 | `agent_payment_already_attempted` | A payment was already attempted with this Idempotency-Key, and is not signed twice. `detail.agent_payment_id` is the earlier attempt. Send a new key to pay again. |
| 409 | `offer_changed` | An approved agent payment found the merchant asking for more, another currency or another address than was approved. Nothing was signed. Ask again. |
| 409 | `agent_customer_required` | The agent acts for no customer, so it can hold no wallet and pay nothing. Register it with a `customer_id`. |
| 409 | `wallet_retired` | The wallet has been retired. Create another, or leave `wallet_id` out. |
| 409 | `wallet_policy_refused` | The policy on the agent's wallet refused the signature at the wallet provider. Nothing was signed. |
| 409 | `token_not_issued` | A delegated payment token could not be issued ready to use, for instance because the payer would have to act. Nothing was sent to the merchant. |
| 502 | `merchant_unreachable` | The merchant's address could not be reached, answered with a redirect, or answered too much, before anything was paid. |
| 502 | `platform_error` | A provider failed. Safe to retry with the same Idempotency-Key. |
| 500 | `internal_error` | Ours. Quote the `request_id` and we can find the call. |
## Capabilities
As this deployment is built. What is switched on for an account is read from `GET /capabilities`.
- `kyb` (available): Business verification
- `identity_verification` (available): Identity verification
- `custody` (available): Custodied wallets
- `virtual_accounts` (available): Virtual accounts
- `payouts_fiat` (available): Fiat payouts
- `payouts_crypto` (available): Stablecoin transfers
- `standing_orders` (available): Standing conversion
- `treasury` (available): Treasury
- `books` (available): Books
- `fx` (available): Exchange rates
- `bank_linking` (available): Bank linking
- `static_memos` (available): Static memos
- `issuance` (request_only): Stablecoin as a service
- `cards` (coming_soon): Cards
- `compliance_requests` (coming_soon): Compliance requests
- `kyc` (coming_soon): Individual verification
- `card_acceptance` (available): Card and bank-debit acceptance
- `usage_meters` (coming_soon): Usage meters
- `checkout_instructions` (available): Stablecoin and bank transfer at checkout
- `agentic_checkout` (preview): Agentic checkout
- `ucp_checkout` (preview): Universal Commerce Protocol checkout
- `agent_tokens` (preview): Delegated payment tokens
- `pay_by_call` (preview): Paying by the call
- `agent_credentials` (preview): Agents paying other merchants
## Payment methods that are not plainly available
The whole matrix is in `GET /capabilities` under `payment_methods`. These rows are per customer, in preview, coming, or not offered:
- `card` (per_customer, `enabled_at_onboarding`): Cards
- `wallet:link` (per_customer, `enabled_at_onboarding`): Wallets (saved cards and device wallets)
- `buy_now_pay_later:klarna` (per_customer, `enabled_at_onboarding`): Klarna
- `buy_now_pay_later:affirm` (per_customer, `enabled_at_onboarding`): Affirm
- `buy_now_pay_later:afterpay_clearpay` (per_customer, `enabled_at_onboarding`): Afterpay and Clearpay
- `local_method:ideal` (per_customer, `enabled_at_onboarding`): iDEAL
- `local_method:bancontact` (per_customer, `enabled_at_onboarding`): Bancontact
- `local_method:eps` (per_customer, `enabled_at_onboarding`): EPS
- `local_method:p24` (per_customer, `enabled_at_onboarding`): Przelewy24
- `local_method:blik` (per_customer, `enabled_at_onboarding`): BLIK
- `local_method:twint` (per_customer, `enabled_at_onboarding`): TWINT
- `local_method:swish` (per_customer, `enabled_at_onboarding`): Swish
- `local_method:mobilepay` (per_customer, `enabled_at_onboarding`): MobilePay
- `local_method:pix` (per_customer, `enabled_at_onboarding`): Pix
- `wallet:cashapp` (per_customer, `enabled_at_onboarding`): Cash App Pay
- `wallet:amazon_pay` (per_customer, `enabled_at_onboarding`): Amazon Pay
- `wallet:revolut_pay` (per_customer, `enabled_at_onboarding`): Revolut Pay
- `local_method:multibanco` (per_customer, `enabled_at_onboarding`): Multibanco
- `local_method:konbini` (per_customer, `enabled_at_onboarding`): Konbini
- `local_method:boleto` (per_customer, `enabled_at_onboarding`): Boleto
- `local_method:oxxo` (per_customer, `enabled_at_onboarding`): OXXO
- `bank_debit:us_bank_account` (per_customer, `enabled_at_onboarding`): ACH debit
- `bank_debit:sepa_debit` (per_customer, `enabled_at_onboarding`): SEPA debit
- `bank_debit:bacs_debit` (per_customer, `enabled_at_onboarding`): Bacs debit
- `bank_transfer_in:faster_payments` (coming_soon, `account_currency_not_switched_on`): Faster Payments into a virtual account
- `bank_transfer_out:fednow` (per_customer, `payee_bank_dependent`): FedNow payout
- `bank_transfer_out:rtp` (not_offered, `inbound_only`): RTP payout
- `bank_transfer_out:faster_payments` (per_customer, `enabled_at_verification`): Faster Payments payout
- `bank_transfer_out:spei` (per_customer, `enabled_at_verification`): SPEI payout
- `bank_transfer_out:pix` (per_customer, `enabled_at_verification`): Pix payout
- `bank_transfer_out:bre_b` (per_customer, `enabled_at_verification`): Bre-B payout
- `bank_transfer_out:co_bank_transfer` (per_customer, `enabled_at_verification`): Colombian bank transfer payout
- `bank_debit_on_demand` (not_offered, `provider_has_no_pull_api`): Pulling money from a bank account into a wallet on demand
- `issued_card` (coming_soon, `card_issuing_not_switched_on`): Cards issued against a balance
- `pay_by_call:stablecoin` (preview, `preview_off_by_default`): Paying by the call in stablecoin (x402 and the Machine Payments Protocol)
- `pay_by_call:agent_token` (preview, `preview_off_by_default`): Paying by the call with a delegated card token (the Machine Payments Protocol)
- `agent_token` (preview, `preview_off_by_default`): A delegated payment token presented by an agent at checkout (seller side)
- `agent_credential` (preview, `agent_paying_preview`): An agent paying other merchants (buyer side)
- `push_to_card` (not_offered, `not_served`): Push to a debit card
- `cash` (not_offered, `not_served`): Cash
- `cheque` (not_offered, `not_served`): Cheque
- `mobile_money` (not_offered, `not_served`): Mobile money
- `crypto_non_stablecoin` (not_offered, `not_served_non_stablecoin`): Crypto assets other than stablecoins
- `stablecoin:{coin}:{chain}` (per_customer, `chain_coin_pair_varies`): Each stablecoin on each chain
## Operations
### Getting started
The two calls worth making first. `/` needs no key and lists what exists; `/whoami` proves the key you were given works and says which environment it is in.
- `GET /` (no key): The index. Public, so a new developer can look before they have a key.
- `GET /openapi.json` (no key): This same reference as an OpenAPI 3.1 document, for Postman, a generator or an agent. Public.
- `GET /whoami` (scope `*`): The partner, project, mode and scopes behind the key you sent.
- `GET /llms.txt` (no key): The API described for a model, as plain Markdown: authentication, the order of calls for the common jobs, idempotency, approvals, every error with what to do next, and every operation. Generated from this reference. Public.
- `GET /agents.md` (no key): The same description addressed to an agent that is about to act. Public.
- `GET /capabilities` (scope `*`): What this project may do right now, and what is merely coming: capabilities, bank rails (and which are enabled customer by customer), the chains and coins the API accepts, `custody` (who holds the money, by name), and `payment_methods`: every way money moves, method by method and direction by direction, each with one status and a reason code. A capability with `test_mode_only: true` works in the sandbox only for now.
- `POST /terms` (scope `customers:write`): Accept the terms a customer must agree to before they can be verified. Body: `{ customer_id }`
### Customers
A customer is a business you bring to the platform. It is verified in its own right, and everything else — wallets, accounts, payees — hangs off one.
- `POST /customers` (scope `customers:write`): Onboard a business, or a person with `type: "individual"` (hosted verification only; identity details and documents are refused for a person). `reliance` carries your own verification results and is accepted only from an account approved for it. Body: `{ legal_name, email, country, external_id?, type?, ownership_threshold?, reliance?: { kyc_screen, ofac_screen, verified_database_at?, verified_govid_at?, verified_proof_of_address_at? } }`
- `GET /customers` (scope `customers:read`): The businesses this project has onboarded.
- `GET /customers/{id}` (scope `customers:read`): One customer, with its verification state, the bank rails it is enabled for (`rails`), what it can be paid with (`card_acceptance.methods`) and everything still owed to either side as one list: `requirements[]` of `{ capability, provider, status, items[], due_by }`.
- `PATCH /customers/{id}` (scope `customers:write`): Correct details on a business. Body: `{ legal_name?, address?, external_id? }`
- `POST /customers/{id}/verification` (scope `customers:write`): Start or re-run verification. Body: `{ }`
- `GET /customers/{id}/verification` (scope `customers:read`): Where verification has got to.
- `POST /customers/{id}/onboarding-link` (scope `customers:write`): A link the business owner opens to finish verification (and card-acceptance setup) on hosted pages. You send no people, birth dates or documents. Body: `{ return_url, refresh_url?, steps? }`
- `GET /onboard/{token}` (no key): The page behind an onboarding link. Public: the token is the credential. Redirects to the next hosted step, then back to your return_url.
- `GET /customers/{id}/payment-methods` (scope `customers:read`): The payment-method matrix for one customer: every method and direction, each cell marked `enabled` from what the card processor reports active on its merchant account and what the banking partner enabled at verification.
- `GET /customers/{id}/people` (scope `customers:read`): The owners, controllers and signers of a business customer, as the banking partner holds them: `{ id, first_name, last_name, email, title, owns, controls, signs, is_director, ownership_percent }`. Names and roles only; nothing from a person's documents is returned.
- `POST /customers/{id}/people` (scope `customers:write`): Name another owner, controller or signer of a business customer, in the form `POST /customers` takes in `people[]`. At least one of `owns`, `controls` and `signs` is true; `address.country` is ISO 3166-1 alpha-3. Identity documents and files are passed on to the banking partner and never stored by Skerry. `reliance` is accepted only from an account approved for it. For example `{"first_name": "Ada", "last_name": "Lovelace", "email": "ada@example.com", "date_of_birth": "1985-12-10", "address": {"line1": "1 Main Street", "city": "Berlin", "postal_code": "10115", "country": "DEU"}, "owns": true, "ownership_percent": 40}`. Body: `{ first_name, last_name, email, date_of_birth, address: { line1, line2?, city, region?, postal_code?, country }, owns?, controls?, signs?, is_director?, ownership_percent?, title?, identity_documents?: [{ type, issuing_country, number?, expires?, front?, back? }], documents?: [{ purpose, file }], reliance? }`
- `DELETE /customers/{id}/people/{person_id}` (scope `customers:write`): Remove a person from a business customer at the banking partner.
- `GET /eligibility` (scope `eligibility:read`): Whether a business like this can be served, from where, for what, with machine-readable reasons. `?country=&business_type=¤cy=&industry=`
### Custody
Where a customer's money sits. A wallet holds stablecoin; an account is fiat at a regulated institution; a card spends from one.
- `POST /customers/{id}/wallets` (scope `wallets:write`): Create a stablecoin wallet, optionally with a label, a purpose and the agent it is for. Covered by mandates as `wallet.create`. Body: `{ chain, label?, purpose?, agent_id? }`
- `GET /customers/{id}/wallets` (scope `wallets:read`): The customer's wallets and balances. Each balance carries the decimal `amount` and `amount_minor`, the same amount in integer minor units of `currency`; each wallet its `label`, `purpose`, `agent_id` and `floor`.
- `GET /customers/{id}/wallets/{wallet_id}/history` (scope `wallets:read`): What has moved in and out of one wallet, newest first. `?limit=`
- `POST /customers/{id}/accounts` (scope `accounts:write`): Open an account payers pay into. `currency` is what the payer sends (usd or eur); `wallet_id` is the customer's wallet the money lands in, converted to the stablecoin named in `delivers` (usdc for usd and eurc for eur unless named). For example `{"currency": "usd", "wallet_id": "", "delivers": "usdc"}`. The answer carries the account's `deposit_instructions`. Body: `{ currency, wallet_id, delivers? }`
- `GET /customers/{id}/accounts` (scope `accounts:read`): The customer's fiat accounts.
- `POST /customers/{id}/accounts/{account_id}/deactivate` (scope `accounts:write`): Stop an account accepting deposits. It keeps its details, money already received is not affected, and it can be reopened. Body: `{ }`
- `POST /customers/{id}/accounts/{account_id}/reactivate` (scope `accounts:write`): Let a deactivated account accept deposits again, with the same details. Body: `{ }`
- `POST /customers/{id}/bank-link` (scope `payees:write`): Start connecting a US bank by logging into it, instead of typing its numbers. Answers `{ link_token, expires_at }` for the bank login page; credentials never reach Skerry. Finish with `POST /customers/{id}/bank-link/exchange`. Body: `{ }`
- `POST /customers/{id}/bank-link/exchange` (scope `payees:write`): Finish a bank login: send the `public_token` the bank login page returned, with the `link_token` it was started with. The link token must be one started for this customer, and is finished once (409 `bank_link_finished`, or `bank_link_expired` after its expiry). The answer is `{ data: [payee] }`, the payees the connection added, each announced as `payee.created`. Covered by mandates as `payee.create`; if it is held, send the same request again with `Skerry-Approval` once a person has approved it. Neither token is stored. Body: `{ link_token, public_token }`
- `POST /customers/{id}/cards` (scope `cards:write`): Issue a card against an account. Body: `{ account_id }`
- `GET /customers/{id}/cards` (scope `cards:read`): The customer's cards.
- `POST /customers/{id}/memos` (scope `accounts:write`): A deposit memo, for chains where one shared address serves everybody. `wallet_id` is the customer's wallet the deposit is delivered to, and fixes the chain; `delivers` is the coin that arrives (usdc unless named) and `currency` what the payer sends (usd unless named). Body: `{ wallet_id, delivers?, currency? }`
- `GET /customers/{id}/memos` (scope `accounts:read`): The customer's deposit memos. A payer who omits the memo cannot be attributed.
### Paying out
A payee is somewhere money can go; a payout sends it there. Quote first when the amount matters — a quote fixes the rate for a short window.
- `POST /customers/{id}/payees` (scope `payees:write`): Add a bank account as a destination for money. The body is the banking partner's external-account fields, passed on as sent: `currency`, `account_type` (`us`, `iban` or `gb`), `account_owner_name` as the bank holds it, `account_owner_type` (business or individual) with `business_name` for a business, `bank_name`, `address` (`street_line_1`, `street_line_2`, `city`, `state`, `postal_code`, and `country` as ISO 3166-1 alpha-3), and the account itself: `account: { routing_number, account_number, checking_or_savings }` for `us`, `iban: { account_number, bic, country }` for `iban`, `account: { sort_code, account_number }` for `gb`. For example `{"currency": "eur", "account_type": "iban", "account_owner_name": "Example GmbH", "account_owner_type": "business", "business_name": "Example GmbH", "address": {"street_line_1": "1 Hauptstrasse", "city": "Berlin", "postal_code": "10115", "country": "DEU"}, "iban": {"account_number": "DE89370400440532013000", "country": "DEU"}}`. Under a mandate that covers `payee.create` the operation allow-list and approval rules apply; if it is held, send the same request again with `Skerry-Approval` once a person has approved it. Body: `{ currency, account_type, account_owner_name, account_owner_type, business_name?, bank_name?, address: { street_line_1, street_line_2?, city, state?, postal_code?, country }, account?: { routing_number, account_number, checking_or_savings } | { sort_code, account_number }, iban?: { account_number, bic?, country } }`
- `GET /customers/{id}/payees` (scope `payees:read`): The customer's payees, each with the receiving bank's name check (`name_check`) where the rail has one.
- `GET /customers/{id}/payees/{payee_id}` (scope `payees:read`): One payee, with the name check as it stands now.
- `DELETE /customers/{id}/payees/{payee_id}` (scope `payees:write`): Remove a payee.
- `POST /customers/{id}/payees/{payee_id}/pause` (scope `payees:write`): Stop paying a payee for now without removing it. It reads `active: false` until resumed. Announced as `payee.updated`. Body: `{ }`
- `POST /customers/{id}/payees/{payee_id}/resume` (scope `payees:write`): Pay a paused payee again. Refused with `customer_frozen` while the customer is frozen. Announced as `payee.updated`. Body: `{ }`
- `POST /customers/{id}/payees/{payee_id}/check` (scope `payees:write`): Ask the receiving bank again whether the name on the account matches, and return the payee with the answer as `name_check`. Often `pending` at first; a later read or the `payee.updated` event carries the bank's answer. Body: `{ }`
- `POST /payouts/quote` (scope `payouts:write`): What a payout would cost, as a quote that binds the instruction. The answer carries `quote_id` and `valid_until` (120 seconds by default); `POST /payouts` with that `quote_id` executes exactly the quoted customer, amount, source, destination and rail, once. For a bank payout it says which rail it would leave on and why (`rail`, `rail_reason`) and carries the name check on the payee (`payee_check`). `fee`, `rate` and the receive amount are what the provider's dry run reported when the quote was made; the provider is not asked to hold them, so this is not a guaranteed rate. `amount` is a decimal string and `amount_minor` the same in integer minor units of the source currency; send either. `fee_minor` is in minor units of `fee_currency`, `receive_amount_minor` of `receive_currency`. Body: `{ customer_id, amount | amount_minor, from, to, rail?, speed? }`
- `POST /payouts` (scope `payouts:write`): Send money to a payee, a wallet or an address. Needs an Idempotency-Key. `amount` is a decimal string in `from.currency`; `amount_minor` is the same amount as integer minor units, and either may be sent. A bank payout may name a `rail` or a `speed` (fastest, cheapest, standard); otherwise the rail is chosen and returned with the reason. `quote_id` executes exactly what was quoted. `execute_at`, `schedule` or `release_on` create a scheduled payout instead of sending now. An outside address is checked for its chain's format and for the coin being on that chain (Base when no chain is named). `travel_rule` carries originator and beneficiary details for an outside address; it is passed on and not kept. The answer carries `amount_minor`, `fee_minor` and `authorisation`. Body: `{ customer_id, amount | amount_minor, from: { currency, wallet_id }, to: { currency, payee_id | wallet_id | address, chain? }, rail?, speed?, quote_id?, execute_at? | schedule?: { every, unit: day|week|month, end_at? | count? } | release_on?: { event, object_id } | "approval", expires_at?, travel_rule?: { originator, beneficiary }, reference?, external_ref?, external_kind?, remittance?: { invoice_number?, amount?, date? }, dry_run? }`
- `POST /payouts/batches` (scope `payouts:write`): Up to 50 payouts in one call. All are checked first, and the batch's total per customer and currency is checked against the caller's daily and monthly limits before anything is sent; each then runs as its own payout and reports its own outcome. Needs an Idempotency-Key. A dry run checks every item and the totals and sends nothing. Body: `{ payouts: [ ...as POST /payouts ], reference?, dry_run? }`
- `GET /payouts/batches` (scope `payouts:read`): Batches, newest first, each with the outcome of each payout in it. `?customer_id=` keeps the batches that hold a payout for that customer.
- `GET /payouts/batches/{id}` (scope `payouts:read`): One batch, with the outcome of each payout in it.
- `GET /customers/{id}/payout-fees` (scope `payouts:read`): Who bears the cost of a bank payout, per currency and rail: `fees.usd.wire` and the like, as the banking partner states them. It decides whether a payee receives the amount sent or that amount less a fee.
- `PATCH /customers/{id}/payout-fees` (scope `customers:write`): Change who bears the cost of a bank payout. Only the entries sent change. An agent's own credential cannot change it. Body: `{ fees: { usd: { wire } } }`
- `POST /customers/{id}/batch-settlement-schedules` (scope `payouts:write`): Collapse the customer's bank payouts on one currency and rail into a single settlement instead of one bank movement per payout. Kept by the banking partner; answers with the schedule's id. An agent's own credential cannot create one. Body: `{ currency, rail }`
- `GET /payouts` (scope `payouts:read`): Payouts this project has made, with the reference each was given. Each carries `amount` (decimal string) and `amount_minor` (integer minor units of `currency`). `?customer_id=&external_ref=`
- `GET /payouts/{id}` (scope `payouts:read`): One payout and where it has got to, with `amount_minor`, `fee_minor` and `authorisation`. While it is paused `status` is `on_hold` and `hold` gives `reason_code`, the reason in plain words and the deadline when there is one. `?wait=&until=` holds the answer until the payout is paid, returned, canceled, undeliverable or return_failed, or reaches the state named, or the wait ends.
- `PATCH /payouts/{id}` (scope `payouts:write`): Change the amount of a payout that is still `awaiting_funds`; once its funds have arrived it is refused with 409 `payout_not_amendable`. Needs an Idempotency-Key. A higher amount is checked as the payout was, for the difference: the freeze, risk rules, mandate limits, budget and approval; if it is held, send the same request again with `Skerry-Approval` once a person has approved it. A lower amount is not checked, and what was already counted against a limit stays counted. The stored record takes the new amount and the difference is booked in the payout's movement. `amount` is a decimal string in the payout's currency; `amount_minor` may be sent instead. Body: `{ amount | amount_minor }`
- `DELETE /payouts/{id}` (scope `payouts:write`): Cancel a payout that has not left yet. Its entries in the books are reversed and what it counted against a spend limit or budget is given back.
- `GET /rates` (scope `*`): The rate between two currencies. `?from=&to=`
### Standing conversion
A standing order converts on a schedule rather than on a call, so a business can hold one currency and settle in another without writing a cron job.
- `POST /customers/{id}/standing-orders` (scope `standing_orders:write`): An address that converts and pays out whatever arrives. `return_instructions` says where a deposit that cannot be converted goes back to. Under a mandate that covers `standing_order.create` the operation allow-list and approval rules apply. Body: `{ chain, currency, delivers_to: { payee_id | wallet_id, currency?, rail? }, reference?, return_instructions?: { address, memo? } }`
- `GET /customers/{id}/standing-orders` (scope `standing_orders:read`): The customer's standing orders.
- `GET /customers/{id}/standing-orders/{standing_order_id}/conversions` (scope `standing_orders:read`): What one standing order has converted, newest first, as the banking partner reports it: each `{ id, amount, currency, status, standing_order_id, occurred_at }`.
### Events and webhooks
Most state changes arrive here rather than in a response, because verification and settlement finish long after the call that started them. Replay is deliberate: a missed delivery is recoverable without asking us.
- `GET /events` (scope `events:read`): What has happened, newest first. `?limit=&type=&types=a,b`. With `?after=` the order changes: the events recorded after that one, oldest first, with `next_after` to pass as the next `after`; `&wait=` then holds the request until there is a new event or the wait ends.
- `POST /events/{id}/replay` (scope `webhooks:write`): Deliver an event again, to every endpoint that wanted it.
- `GET /events/types` (scope `events:read`): The catalogue of event types: every type this API sends, the object it is about and when it is sent. `event_types` on an endpoint are checked against it.
- `POST /webhooks` (scope `webhooks:write`): Register an endpoint. `url` must be https. `event_types` left out, empty or `*` means every event; a type the catalogue does not have is refused. The endpoint belongs to the project of the credential that registers it and counts against that project's plan. Body: `{ url, event_types? }`
- `GET /webhooks` (scope `webhooks:read`): The endpoints registered in this environment, each with the `project_id` it belongs to.
- `PATCH /webhooks/{id}` (scope `webhooks:write`): Change an endpoint's URL, events or status, held to the same checks as a new endpoint. Body: `{ url?, event_types?, status? }`
- `DELETE /webhooks/{id}` (scope `webhooks:write`): Remove an endpoint.
- `POST /webhooks/{id}/roll-secret` (scope `webhooks:write`): Replace an endpoint's signing secret. Deliveries carry a signature for both secrets (`v1=…,v1=…`) during the overlap. Body: `{ overlap_hours? }`
### Accepting payments
Take card and wallet payments for a customer. The payer pays on a hosted page, so card details never reach Skerry or you. Amounts are integer minor units next to a currency code. Build orders from `payment.succeeded`, never from the payer's browser returning to your site.
- `POST /checkout-sessions` (scope `payments:write`): Start a hosted checkout for a payer. `payment_instructions` adds stablecoin and bank transfer: the session then carries a deposit address and bank details with a reference to quote, and completes when the money arrives. A customer without card acceptance can open a session that has instructions only. Body: `{ customer_id, amount | line_items, currency, success_url, cancel_url?, methods?, payment_instructions?: { methods: [stablecoin | bank_transfer], wallet_id, chain?, coin? }, authentication?, capture?, save_method?, payer_id? | payer?, expires_at?, reference?, external_ref?, external_kind?, metadata?, ui?, dry_run? }`
- `GET /checkout-sessions` (scope `payments:read`): Checkout sessions, newest first. `?customer_id=&status=&external_ref=`
- `GET /checkout-sessions/{id}` (scope `payments:read`): One session. While open, this also asks the processor so a missed webhook cannot mislead. A session with payment instructions carries them, with `amount_received`, `amount_remaining` and `amount_overpaid`.
- `POST /checkout-sessions/{id}/expire` (scope `payments:write`): Close an open session so it can no longer be paid. Body: `{ }`
- `POST /payment-links` (scope `payments:write`): A reusable or single-use link to a hosted checkout for a fixed amount. Body: `{ customer_id, amount, currency, description?, reusable?, max_uses?, expires_at?, reference?, external_ref?, external_kind?, success_url?, dry_run? }`
- `GET /payment-links` (scope `payments:read`): Payment links, newest first. `?customer_id=&external_ref=`
- `GET /payment-links/{id}` (scope `payments:read`): One payment link, with how many times it has been used.
- `PATCH /payment-links/{id}` (scope `payments:write`): Pause, resume or disable a link. Body: `{ status }`
- `GET /payments` (scope `payments:read`): Payments, newest first. `?customer_id=&status=&reference=&external_ref=&payer_id=`
- `GET /payments/{id}` (scope `payments:read`): One payment: status, fee, amounts received and refunded, and `risk` (level, score and how the payer's authentication ended) where the processor reports them. A payment that did not come by card also carries `method` (stablecoin, bank_transfer or machine), `payer_ref` and `amount_overpaid`; one an agent made carries `agent: { initiated, token_id, platform, token_limits, indicators }` and `agent_identity`. `?wait=&until=` holds the request until the payment succeeds, fails, is cancelled, refunded or disputed, or reaches the named state.
- `POST /payments` (scope `payments:write`): Charge a saved method with the payer absent, under the mandate they gave; or, with `payment_token`, charge a delegated payment token an agent holds for its buyer (a preview: 409 `capability_coming_soon` until it is switched on for the customer). Needs an Idempotency-Key. Body: `{ customer_id, payer_id, payment_method_id, amount, currency, off_session: true, authentication?, capture?, reference?, external_ref?, external_kind?, description?, metadata? } | { customer_id, amount, currency, payment_token: { type: "shared_payment_token", id }, reference?, external_ref?, external_kind?, description?, metadata? }`
- `POST /payments/{id}/capture` (scope `payments:write`): Collect funds that were held. May capture less than the hold. Body: `{ amount? }`
- `POST /payments/{id}/cancel` (scope `payments:write`): Release a hold, or abandon a payment that has not succeeded. Body: `{ }`
- `POST /payments/{id}/refunds` (scope `refunds:write`): Refund all or part of a payment. A payment made by stablecoin or bank transfer goes back to where it came from when that is on record, otherwise to `destination`. Needs an Idempotency-Key. Body: `{ amount?, reason?, destination?: { address, chain? } | { payee_id }, external_ref?, external_kind? }`
- `GET /payments/{id}/refunds` (scope `payments:read`): Refunds made against a payment.
- `GET /disputes` (scope `payments:read`): Disputes against payments, newest first. `?customer_id=&status=`
- `GET /disputes/{id}` (scope `payments:read`): One dispute, with the date evidence is due.
- `POST /disputes/{id}/evidence` (scope `payments:write`): Send evidence, and submit it when complete: text fields, file fields (the id of an uploaded file) and `enhanced_evidence`. Body: `{ evidence: { ... }, submit? }`
- `POST /disputes/{id}/files` (scope `payments:write`): Upload a file to use as evidence: PDF, JPEG or PNG, up to 5 MB, base64-encoded. Body: `{ filename, content_type, data }`
- `GET /disputes/{id}/files` (scope `payments:read`): Files uploaded for a dispute, newest first.
- `GET /disputes/{id}/suggested-evidence` (scope `payments:read`): A draft of the text evidence from what is on record: the session, what was bought, the delegated token and its limits, how the calling agent identified itself, and the times. For a person to review and send; nothing is submitted.
- `GET /customers/{id}/unmatched-deposits` (scope `payments:read`): Deposits that could not be credited to a checkout session (wrong coin or chain, no reference, an unknown reference, or late), waiting for a person. `?status=`
- `POST /customers/{id}/unmatched-deposits/{deposit_id}/assign` (scope `payments:write`): Credit a deposit that was set aside to the checkout session it was meant for. Body: `{ checkout_session_id }`
- `POST /customers/{id}/unmatched-deposits/{deposit_id}/refund` (scope `refunds:write`): Send a deposit that was set aside back, whole: to where it came from when that is on record, otherwise to `destination`. Needs an Idempotency-Key. Body: `{ destination?: { address, chain? } | { payee_id } }`
- `GET /settlements` (scope `settlements:read`): Card proceeds on their way to the business's bank, newest first. `?customer_id=`
- `GET /settlements/{id}` (scope `settlements:read`): One settlement.
- `POST /settlements` (scope `settlements:write`): Send proceeds to the business's bank now. Needs an Idempotency-Key. Body: `{ customer_id, amount, currency }`
- `GET /customers/{id}/balances` (scope `customers:read`): What the customer holds per currency: card proceeds (available, pending) and stablecoin.
### Payers, saved methods and subscriptions
A payer is someone who pays your customer. Save a method once, with their agreement, and charge it later or on a schedule. The agreement is the mandate. Card numbers are never returned.
- `POST /payers` (scope `payments:write`): Record someone who pays a customer. Body: `{ customer_id, email?, name?, external_id? }`
- `GET /payers` (scope `payments:read`): Payers, newest first. `?customer_id=`
- `GET /payers/{id}` (scope `payments:read`): One payer.
- `GET /payers/{id}/payment-methods` (scope `payments:read`): Methods the payer saved: brand, last four, expiry, and the mandate.
- `DELETE /payers/{id}/payment-methods/{method_id}` (scope `payments:write`): Forget a saved method and revoke its mandate.
- `POST /customers/{id}/payment-method-setups` (scope `payments:write`): A hosted page where a payer saves a method for later, without paying now. Body: `{ payer_id | payer, success_url, cancel_url }`
- `POST /subscriptions` (scope `payments:write`): Charge a payer on a schedule from a saved method. Needs an Idempotency-Key. Under a mandate that covers `subscription.create`, the amount of one period (integer minor units) is checked against the mandate and counted once. Body: `{ customer_id, payer_id, payment_method_id, currency, interval, interval_count?, items: [{ name, amount, quantity? }], trial_days?, reference?, external_ref?, external_kind?, dry_run? }`
- `GET /subscriptions` (scope `payments:read`): Subscriptions, newest first. `?customer_id=&status=&payer_id=`
- `GET /subscriptions/{id}` (scope `payments:read`): One subscription.
- `POST /subscriptions/{id}/cancel` (scope `payments:write`): End a subscription now or at the end of the paid period. Body: `{ at_period_end? }`
- `POST /meters` (scope `payments:write`): A usage meter on a customer's merchant account, for billing by use. Switched on account by account. Body: `{ customer_id, name, event_name, aggregation? }`
- `GET /meters` (scope `payments:read`): Meters, newest first. `?customer_id=`
- `POST /meters/{id}/events` (scope `payments:write`): Record usage by a payer against a meter. `identifier` makes a retry count once. Body: `{ payer_id, value, identifier?, timestamp? }`
- `GET /meters/{id}/summary` (scope `payments:read`): What a payer has used over a period. `?payer_id=&from=&to=&group_by=hour|day`
### Limits, agents and approvals
Constrain what a key, or an agent acting through it, can move. A policy names the business accountable and the person who approved it, caps amounts and counts, restricts destinations, wallets and currencies, and can require one or two people to approve. With its purpose and the evidence that a person agreed to it, a policy is a mandate: `mandate_id` is the policy id. A key that has ever had a policy stays restricted. Amount limits are integer minor units: of the family's fiat currency (cents) under `scope: "family"`, where USD is limited together with USDC, USDT, USDB, OUSD and PYUSD, and EUR with EURC; of each currency's own smallest unit under `scope: "currency"`.
- `POST /spend-policies` (scope `policies:write`): Limit a key or an agent: a mandate. `scope` is required, `family` or `currency`, and decides the unit of every amount limit (see the group note); a request without it is refused with `invalid_request` naming `scope`. `operations` may name payment.charge, refund.create, payout.create, conversion.create, settlement.create, checkout.create, payment_link.create, subscription.create, standing_order.create, payee.create and wallet.create. `max_count` caps operations per `count_window` (day or month, rolling). `approve_operations` always wait for a person. `checkout_above` is the amount above which checkout sessions and payment links are checked. `merchant_allow_list` applies to operations that name a merchant; `wallet_allow_list` to the wallet money leaves. `evidence` records consent; with `consent_required` the mandate is inactive until verified evidence exists. The answer carries `mandate_id`, `summary` (the rule in sentences, generated from it) and `active`. Body: `{ customer_id, approved_by, scope: family | currency, key_id? | agent_id?, purpose?, max_amount?, daily_max?, monthly_max?, max_count?, count_window?, approval_above?, second_approval_above?, approvers_required?: 1 | 2, approval_evidence?: none | passkey, approval_expires_minutes?, approve_operations?, checkout_above?, currencies?, operations?, payee_allow_list?, merchant_allow_list?, wallet_allow_list?, valid_from?, expires_at?, consent_required?, evidence?: { method, subject, at, assertion?, attestation? } }`
- `GET /spend-policies` (scope `policies:read`): Policies, newest first, each with `usage`: what it has let through in the last 24 hours and 30 days and what remains, per currency family or currency, and how many operations. `?customer_id=&key_id=&status=`
- `GET /spend-policies/{id}` (scope `policies:read`): One policy, with `summary`, `consent` (the evidence attached to the rule as it stands) and `active`.
- `PATCH /spend-policies/{id}` (scope `policies:write`): Change a policy's amounts, counts, approval rules, purpose or dates. Who it binds and its allow-lists cannot change. Changing the rule detaches the consent evidence recorded for it. Changing `scope` changes the unit of every amount, so each amount limit the policy has must be sent again in the same request. Body: `{ max_amount?, daily_max?, monthly_max?, max_count?, count_window?, approval_above?, second_approval_above?, approvers_required?, approval_evidence?, approval_expires_minutes?, checkout_above?, purpose?, scope?, consent_required?, valid_from?, expires_at?, name? }`
- `POST /spend-policies/{id}/revoke` (scope `policies:write`): Revoke a policy, immediately. Body: `{ reason? }`
- `POST /spend-policies/{id}/consent` (scope `policies:write`): Record that a person agreed to the mandate as it stands. The evidence is stored with the mandate's canonical text and its SHA-256. `partner_attested` is taken on your word; `passkey` and `oauth` are `verified` only with an `attestation` signed by the configured identity provider naming the subject and the mandate hash, and are `unverified` when no identity provider is configured; `console_session` from a partner key is `unverified`. Refuses an agent's own credential. Body: `{ method: console_session | passkey | oauth | partner_attested, subject, at, assertion?, attestation? }`
- `GET /spend-policies/{id}/consent` (scope `policies:read`): The evidence recorded for a mandate, newest first, each with the text it was about and whether it is the one attached now.
- `GET /spend-policies/{id}/usage` (scope `policies:read`): The operations counted under a mandate, newest first: `op`, `amount` (integer minor units of `currency`; 0 for an operation that is checked and never counted, such as a checkout session), `payee_display` (the destination as the operation named it: a payee, payer or wallet id, an address, or `own_bank`), `produced` (`{ type, id }` of the object it created, where that is on record), `counted` (false once the operation failed, was cancelled or was returned, so it no longer counts against the limits) and `created_at`.
- `GET /mandates` (scope `policies:read`): A customer's spend policies as mandates (`object: "mandate"`, same ids), each with `usage`: per currency family or currency, what was spent in the last 24 hours and 30 days and what remains, in integer minor units of the unit named, and how many operations were let through. `?customer_id=` (required) `&status=`
- `GET /approvals` (scope `approvals:read`): Operations held for a person to approve, newest first, each with `payee_display`: the destination as a person would recognise it, where there is one, and `decisions`: each decision recorded so far, oldest first, as `{ decision, actor_type, actor_ref, note, created_at }`. `actor_ref` is a key's id, a member's id or the reference an approver's links were made for; never a secret or a token. `?customer_id=&status=pending`
- `GET /approvals/{id}` (scope `approvals:read`): One held operation, as it was asked, with its `decisions`. `?wait=&until=` holds the request until it is approved, denied, executed, failed or expired, or reaches the named state.
- `POST /approvals/{id}/approve` (scope `approvals:write`): Approve a held operation and run it once. The approving key must not be the one that asked and must not be acting as an agent. Where two approvers are needed the first approval leaves it held (`status: pending`). Where the mandate asks for passkey evidence, send it as `evidence`. For an operation the route itself performs (checkout sessions, payment links, subscriptions, standing orders, payees, wallets) approval does not run it: the answer says to send the request again with `Skerry-Approval`. Body: `{ note?, evidence? }`
- `POST /approvals/{id}/deny` (scope `approvals:write`): Refuse a held operation. Body: `{ note? }`
- `POST /approvals/{id}/links` (scope `approvals:write`): Make an approve link and a deny link for one person. Each opens a Skerry page stating what will run, works once and expires (`expires_minutes`, 60 by default, at most 1440). The decision is recorded against `approver`, your reference for that person. Refuses an agent. Body: `{ approver, email?, expires_minutes? }`
- `GET /approve/{token}` (no key): The page behind an approval link. Public: the token is the credential. States the operation, amount, currency, destination, agent, mandate and why it was held. Opening it decides nothing.
- `POST /approve/{token}` (no key): The page's confirm button: uses the link, records the decision against its approver and, when the operation is now approved, runs it. Public: the token is the credential.
- `GET /customers/{id}/limits` (scope `customers:read`): What a key or an agent may still move, per window. Without a name, the calling credential's own. With `?key_id=` or `?agent_id=`, another key's or agent's of the same account: that needs `policies:read` as well and is refused to an agent's own credential.
### Agents and credentials
Register the agents that act through the API, give each its own key, and stop one without touching the others. A key bound to an agent is that agent on every call and can only move money under a spend policy. An agent can also sign its requests with a registered Ed25519 key (HTTP Message Signatures, RFC 9421), and can delegate a narrower part of its authority to a child agent. Where access tokens are accepted, an OAuth client approved for an agent can obtain tokens that act as it.
- `POST /agents` (scope `agents:write`): Register an agent: who it is, who operates it, the customer it acts for and who authorised it. Body: `{ name, authorised_by, customer_id?, operator?, description?, signature_required?, public_keys? }`
- `GET /agents` (scope `agents:read`): Agents, newest first. `?customer_id=&status=&parent_agent_id=`
- `GET /agents/{id}` (scope `agents:read`): One agent, with its status and signing keys.
- `PATCH /agents/{id}` (scope `agents:write`): Change an agent's name, operator, description or whether it must sign requests. Body: `{ name?, operator?, description?, signature_required? }`
- `POST /agents/{id}/suspend` (scope `agents:write`): Suspend an agent. Its keys, tokens, signatures and policies are refused from the next call, and so are the agents it delegated to. Body: `{ reason? }`
- `POST /agents/{id}/resume` (scope `agents:write`): Resume a suspended agent. Body: `{ reason? }`
- `POST /agents/{id}/revoke` (scope `agents:write`): Revoke an agent permanently, with every agent it delegated to and all their keys. Body: `{ reason? }`
- `POST /agents/{id}/keys` (scope `keys:write`): Mint a key bound to the agent. The secret is shown once. It may carry only scopes the calling key holds. Body: `{ scopes, name?, expires_at? }`
- `POST /agents/{id}/public-keys` (scope `agents:write`): Register an Ed25519 public key (JWK) the agent signs requests with. Body: `{ jwk, expires_at? }`
- `GET /agents/{id}/public-keys` (scope `agents:read`): The agent's signing keys in force.
- `DELETE /agents/{id}/public-keys/{kid}` (scope `agents:write`): Remove a signing key.
- `POST /agents/{id}/oauth-clients` (scope `agents:write`): Approve an OAuth client of the authorisation server to obtain access tokens that act as the agent with no person present. It may be given only scopes the calling key holds, and never `*`. Body: `{ client_id, scopes }`
- `GET /agents/{id}/oauth-clients` (scope `agents:read`): The OAuth clients approved to act as the agent, newest first, as `oauth_clients: [{ client_id, issuer, scopes, created_at }]`. A withdrawn approval is not listed.
- `DELETE /agents/{id}/oauth-clients` (scope `agents:write`): Withdraw an OAuth client's approval. `?client_id=`. The next token it asks for as this agent is refused.
- `POST /agents/{id}/delegations` (scope `agents:write`): Called with the agent's own credential: create a child agent with its own key and a narrower policy that expires no later than the parent's. Body: `{ name, description?, scopes?, operations?, currencies?, payee_allow_list?, max_amount?, daily_max?, monthly_max?, approval_above?, expires_at? }`
- `GET /keys` (scope `keys:read`): Keys in this environment, newest first, never with a secret, each with the `project_id` it belongs to. `?agent_id=&status=`
- `GET /keys/{id}` (scope `keys:read`): One key: scopes, bound agent, last use, expiry.
- `POST /keys/{id}/roll` (scope `keys:write`): A new secret for the same key. The old one keeps working for the overlap (24 hours by default, 0 allowed). Body: `{ overlap_hours? }`
- `POST /keys/{id}/revoke` (scope `keys:write`): Revoke a key, immediately. Body: `{ reason? }`
- `GET /.well-known/http-message-signatures-directory` (no key): Agents' public signing keys as a key directory (a JWK Set). Public. `?agent=` or `?partner=&mode=live|sandbox`
- `GET /.well-known/oauth-protected-resource` (no key): OAuth protected-resource metadata (RFC 9728). Public. 404 when access tokens are not accepted on this deployment.
### Controls and audit
Stop money leaving, for one customer or for the whole account, and read back who did what. Every audit entry is chained to the one before it by a hash, so the trail can be checked for edits.
- `POST /customers/{id}/freeze` (scope `controls:write`): Freeze a customer: every money-out operation for it is refused with `customer_frozen`. Body: `{ reason? }`
- `GET /customers/{id}/freeze` (scope `controls:read`): Whether one customer is frozen: `frozen`, `reason`, `frozen_at`, `frozen_by_key`, `frozen_by_agent`, and `account_frozen` when the whole account is frozen in this environment as well.
- `POST /customers/{id}/unfreeze` (scope `controls:write`): Lift a customer's freeze. Body: `{ reason? }`
- `POST /controls/freeze` (scope `controls:write`): Freeze the whole account in this environment: every money-out operation is refused with `partner_frozen`. Body: `{ reason? }`
- `POST /controls/unfreeze` (scope `controls:write`): Lift the account's freeze. Body: `{ reason? }`
- `GET /controls` (scope `controls:read`): What is frozen right now: the account and each frozen customer.
- `GET /audit` (scope `audit:read`): The audit trail, newest first. `?object_type=&object_id=&action=&agent_id=&key_id=`
- `GET /audit/verify` (scope `audit:read`): Walk the audit hash chain and report the first entry that does not fit. `?after=` continues a long walk.
### Conversion and ledger
Convert a customer's own balance between currencies with a priced, expiring quote, and export the double-entry books for an accountant.
- `POST /conversions/quote` (scope `conversions:write`): A priced offer to convert, valid for a short time and usable once. Body: `{ customer_id, from: { currency, wallet_id }, to: { currency, wallet_id | payee_id }, amount }`
- `POST /conversions` (scope `conversions:write`): Execute a quote. Needs an Idempotency-Key. Body: `{ quote_id, external_ref?, external_kind? }`
- `GET /conversions` (scope `conversions:read`): Conversions, newest first. `?customer_id=`
- `GET /conversions/{id}` (scope `conversions:read`): One conversion and whether it has settled. `?wait=&until=` holds the request until it is completed or failed, or reaches the named state.
- `GET /customers/{id}/ledger` (scope `ledger:read`): Double-entry entries for a period, as JSON or CSV, in booking order; `order=desc` reads newest first. Each JSON entry also carries `side`, `amount_minor`, `status` (pending or posted), `counterparty`, `rail`, `external_ref`, `external_kind`, `movement_id` (the receipt's id) and `reverses`. `?from=&to=&kind=&status=&external_ref=&order=desc&format=csv`
### Books and receipts
Skerry keeps the money and a signed record of every movement. Invoices, bills, tax, statements, reconciliation and accountant exports are kept in the customer's books: connect the customer's Rivier project once, a person approves it there, and every movement is delivered to it. What Skerry is asked about those subjects it answers with a link into the books, or with 409 `books_not_connected` and the next step.
- `POST /customers/{id}/books` (scope `books:write`): Start connecting a customer's books. Returns `connect_url`, where a signed-in person approves the connection. Body: `{ provider?, project_id?, return_url? }`
- `GET /customers/{id}/books` (scope `books:read`): Whether the books are connected, to which project, and how delivery is going. `?probe=true` also asks the provider.
- `DELETE /customers/{id}/books` (scope `books:write`): End the connection. Movements stop being delivered.
- `POST /customers/{id}/books/backfill` (scope `books:write`): Send the movements of a period to the books again. Safe to repeat. Body: `{ from, to }`
- `GET /customers/{id}/books/deliveries` (scope `books:read`): What was sent to the books and how it went, newest first. `?status=`
- `GET /customers/{id}/books/links` (scope `books:read`): Where the statement, invoices, bills, reports and reconciliation are in the customer's books. `?from=&to=&external_ref=&external_kind=`
- `POST /books/connections` (no key): Complete a connection. Called by the books provider's server after a person approved; the one-time token is the credential, and the answer carries the feed's signing secret once. Public. Body: `{ token, project_id, connection_id, feed_url, links: { statement, invoices, bills, reports, reconcile }, scopes? }`
- `GET /receipts/{id}` (scope `ledger:read`): The signed receipt of one movement: amounts, fee, rate, the other party, rail, references, and the key, agent and policy that caused it.
- `GET /.well-known/skerry-receipt-keys` (no key): The public keys receipts are signed with. Public.
### Budgets
Set aside part of a wallet's balance for one mandate or one agent. A budget is an earmark kept by Skerry: nothing moves at the provider. An operation under a budgeted mandate, or by a budgeted agent, may only spend from its budget, and every other caller may only spend what is not set aside. Amounts are integer minor units of the budget's currency.
- `POST /customers/{id}/budgets` (scope `budgets:write`): Set aside an amount of a wallet's balance for a mandate or an agent. `amount` and `low_below` are integer minor units of `currency`. Refused with `insufficient_funds` when the wallet does not hold that much free. Not for an agent's own credential. Body: `{ wallet_id, currency, amount, mandate_id | agent_id, name?, low_below? }`
- `GET /customers/{id}/budgets` (scope `budgets:read`): The customer's budgets, newest first, each with `amount`, `spent` and `remaining` in minor units of its currency. `?status=active|released`
- `GET /customers/{id}/budgets/{budget_id}` (scope `budgets:read`): One budget, with what was spent from it and what remains.
- `POST /customers/{id}/budgets/{budget_id}/top-up` (scope `budgets:write`): Add to a budget. `amount` is integer minor units of the budget's currency. Re-arms `budget.low` and `budget.exhausted`. Body: `{ amount }`
- `POST /customers/{id}/budgets/{budget_id}/release` (scope `budgets:write`): Release a budget: what was not spent is free for other callers again. Body: `{ }`
### Customer controls
Controls set on one customer that apply to every caller, whatever its mandate: holding the first payout to a destination the customer has never paid, a cooling-off period, velocity and anomaly rules, a withdrawal allow-list, and the people who approve held operations by email. Every control is off until it is set, and an agent's own credential cannot change one.
- `GET /customers/{id}/payee-controls` (scope `controls:read`): How a new destination is treated: `new_payee_hold`, `payee_cooling_off_minutes` and `cooling_off_max`.
- `PUT /customers/{id}/payee-controls` (scope `controls:write`): Hold the first payout to a payee, wallet or address the customer has never paid (an approval with reason `new_payee`), and limit what a newly trusted destination can be paid during its cooling-off period. `cooling_off_max` is integer minor units of the fiat currency of the payout's unit family (cents); null means nothing. Body: `{ new_payee_hold?, payee_cooling_off_minutes?, cooling_off_max? }`
- `POST /customers/{id}/trusted-payees` (scope `payees:write`): Trust a destination before it is first paid, so that payout is not held. Its cooling-off period starts now. A person's decision: refused for an agent's own credential. Body: `{ payee_id | wallet_id | address, chain? }`
- `GET /customers/{id}/trusted-payees` (scope `payees:read`): Destinations the customer has paid or a person has trusted, newest first, with how each came to be trusted (`source`: paid, pre_approved or approved).
- `GET /customers/{id}/risk-rules` (scope `controls:read`): The customer's velocity and anomaly rules. A rule that is null is off.
- `PUT /customers/{id}/risk-rules` (scope `controls:write`): Replace the customer's risk rules. Each rule has `action`: `approve` turns an operation that trips it into an approval with the reason, `refuse` refuses it with `risk_refused`. Rules are counted from recorded operations, outside any model. Body: `{ rules: { velocity?: { max_count, window_minutes, action }, amount_anomaly?: { factor, min_samples?, action }, first_use?: { dimensions?, action }, lookalike_address?: { max_different?, action } } }`
- `GET /customers/{id}/withdrawal-allow-list` (scope `controls:read`): The destinations this customer's payouts may go to. `destinations: null` means there is no list.
- `PUT /customers/{id}/withdrawal-allow-list` (scope `controls:write`): Replace the withdrawal allow-list: payee ids, wallet ids and addresses, up to 500. While a list is in force every payout by any caller must go to a destination on it (`destination_not_allowed` otherwise), in addition to any mandate's own allow-list. `null` removes the list. Body: `{ destinations: [ ... ] | null }`
- `POST /customers/{id}/approvers` (scope `approvals:write`): Name a person who is emailed an approve link and a deny link when one of this customer's operations is held. `reference` is your identifier for the person. `mail_configured` in the answer says whether this deployment sends mail. Body: `{ email, reference, name? }`
- `GET /customers/{id}/approvers` (scope `approvals:read`): The customer's approvers, and whether this deployment sends mail.
- `DELETE /customers/{id}/approvers` (scope `approvals:write`): Remove an approver. `?email=`
### Wallet controls
A wallet with a purpose: a label, a purpose, the agent it is for and a floor, kept by Skerry beside the provider's wallet. Sweep rules keep a balance in range by moving the difference on a schedule; each run is an ordinary payout made as the key that created the rule, under that key's mandates. To confine an agent to named wallets, set `wallet_allow_list` on its mandate.
- `GET /customers/{id}/wallets/{wallet_id}` (scope `wallets:read`): One wallet: balances (decimal `amount` and `amount_minor` in integer minor units of `currency`), `label`, `purpose`, `agent_id` and `floor`.
- `PATCH /customers/{id}/wallets/{wallet_id}` (scope `wallets:write`): Change a wallet's label, purpose, agent or floor. `floor.amount` is integer minor units of `floor.currency`; `balance.low` is sent when the balance falls under it. Body: `{ label?, purpose?, agent_id?, floor?: { amount, currency } | null }`
- `POST /customers/{id}/sweep-rules` (scope `sweeps:write`): Keep at most (`keep_at_most`, with `to`) or at least (`keep_at_least`, with `from`) an amount in a wallet, checked every `every_minutes` (5 or more). `amount` is integer minor units of `currency`. Both currencies must be of one unit family. Body: `{ wallet_id, currency, kind, amount, to?: { wallet_id | payee_id, currency }, from?: { wallet_id, currency }, every_minutes }`
- `GET /customers/{id}/sweep-rules` (scope `sweeps:read`): The customer's sweep rules, newest first, with how each last ran (`last_result`). `?status=active|canceled`
- `GET /customers/{id}/sweep-rules/{rule_id}` (scope `sweeps:read`): One sweep rule.
- `DELETE /customers/{id}/sweep-rules/{rule_id}` (scope `sweeps:write`): Cancel a sweep rule. A payout it already made is not recalled.
### Scheduled payouts and waiting
A payout can run later instead of now: send `POST /payouts` with `execute_at` (a future time), `schedule` (`{ every, unit: day | week | month, end_at? | count? }`) or `release_on` (`{ event, object_id }`, or `"approval"` for a person). It needs an Idempotency-Key and answers with a scheduled payout. Each run is an ordinary payout made as the key that scheduled it, under that key's mandate as it stands when it runs; a frozen customer, a revoked or expired key, a revoked agent or a revoked mandate cancels the schedule with the reason. A payout with `release_on` sets its amount aside in the source wallet until it is released, cancelled or lapses at `expires_at` (7 days by default, 90 at most). `travel_rule` cannot be scheduled. `amount_minor` on a scheduled payout is integer minor units of `currency`; `amount` is the same as a decimal string. To wait for an outcome without a webhook, add `?wait=` to a read.
- `GET /payouts/scheduled` (scope `payouts:read`): Scheduled payouts, newest first. `?customer_id=&status=scheduled|waiting|running|completed|canceled|failed`
- `GET /payouts/scheduled/{id}` (scope `payouts:read`): One scheduled payout: when it runs next, how many times it has run, the last payout it made and how the last run went.
- `DELETE /payouts/scheduled/{id}` (scope `payouts:write`): Cancel a scheduled payout that has not run, or stop a recurring one. What was set aside is free again. Payouts already made are not recalled. Body: `{ reason? }`
- `POST /payouts/{id}/release` (scope `payouts:write`): A person releases a conditional payout (`{id}` is the scheduled payout's id): it runs now, spending what was set aside for it. An agent's own credential is refused. Body: `{ }`
### Authorisation evidence
What authorised a transaction. Payments, payouts, refunds, conversions, settlements, subscriptions, checkout sessions and payment links carry an `authorisation` block: `mandate_id` (the policy id), `policy_id`, `agent_id`, `key_id`, `consent_id` and `approval_id`. The routes here return the whole record behind those ids for a dispute.
- `GET /payments/{id}/authorisation` (scope `audit:read`): The evidence for one payment: the mandate's text, hash and summary, the consent evidence, the approval with each decision and who made it, the agent and the agents it was delegated from, how the request was authenticated, and the audit entries about it.
- `GET /payouts/{id}/authorisation` (scope `audit:read`): The same record for one payout.
- `GET /refunds/{id}/authorisation` (scope `audit:read`): The same record for one refund.
- `GET /conversions/{id}/authorisation` (scope `audit:read`): The same record for one conversion.
### Sandbox
Helpers that exist only with a test key, so an integration can be built and exercised without a browser or a bank.
- `POST /sandbox/checkout-sessions/{id}/complete` (scope `payments:write`): Act as the payer: succeed, decline, run short of funds, or open a dispute. Body: `{ outcome }`
- `POST /sandbox/approvals/{id}/decide` (scope `approvals:write`): Decide a held operation as a person would, without a second key or a link. Where two approvers are needed, call it twice with different `approver` values. Body: `{ decision: approve | deny, approver? }`
- `POST /sandbox/payouts/scheduled/{id}/advance` (scope `payouts:write`): Run a scheduled payout now instead of at its time, as the worker would. A recurring schedule moves on to its next run. Body: `{ }`
- `POST /sandbox/events` (scope `events:write`): Record a named event about an object, marked `sandbox: true`, so a conditional payout waiting for it is released and a webhook receiver can be exercised. Body: `{ type, object_id, customer_id?, payload? }`
- `POST /sandbox/customers/{id}/risk-rules/trip` (scope `controls:write`): Set one risk rule on a customer, with the history it needs, so that the next matching operation trips it. The answer says what to send. Body: `{ rule, action? }`
- `POST /sandbox/budgets/{id}/exhaust` (scope `budgets:write`): Mark what remains of a budget as spent in Skerry's record, so `budget.exhausted` is emitted and the next operation under it is refused. Nothing is sent to a provider. Body: `{ }`
- `POST /sandbox/checkout-sessions/{id}/deposit` (scope `payments:write`): Act as the payer on a session's stablecoin or bank-transfer instructions: pay it, underpay, overpay, pay late, or send the wrong coin, chain or reference. `amount` is integer minor units of the session's currency; left out, it is what is still owed. Body: `{ method?, amount?, coin?, chain?, reference?, state?, at?, deposit_ref? }`
### Agentic checkout: what a customer sets
Let agent platforms buy from a customer through the Agentic Commerce Protocol. The customer issues each platform a bearer token, registers where its own system is asked for line items, totals, tax and fulfilment, and says what agents may do. Skerry holds no catalogue and computes no tax. A preview: sessions can be opened and priced, and completing one needs delegated payment tokens to be switched on for the customer.
- `POST /customers/{id}/agentic/credentials` (scope `agentic:write`): Issue a bearer token to an agent platform. The secret, and the signing and webhook secrets when asked for, are shown once. The token is kept as a hash and the signing and webhook secrets sealed; a deployment with no sealing key refuses a credential that needs either with 503 `platform_unavailable`. Body: `{ name, platform?, webhook_url?, require_signature? }`
- `GET /customers/{id}/agentic/credentials` (scope `agentic:read`): The customer's agent-platform credentials, never with a secret.
- `DELETE /customers/{id}/agentic/credentials/{credential_id}` (scope `agentic:write`): Revoke a credential.
- `POST /customers/{id}/agentic/pricing-callback` (scope `agentic:write`): Register the URL asked for line items, totals, tax and fulfilment each time a session is created or changed. Signed, with a short timeout. `url: null` removes it; without one only fixed-amount payment links can be sold. Body: `{ url }`
- `GET /customers/{id}/agentic/settings` (scope `agentic:read`): What the customer allows agents, and the address agent platforms call.
- `POST /customers/{id}/agentic/settings` (scope `agentic:write`): Change what the customer allows agents: verified agents only, the refunds an agent may start, the links a session shows, whether payment links answer automated clients with a challenge, and how long unused credit is refundable. Body: `{ require_verified_agent?, refunds?: { window_days?, max_amount?, needs_approval? }, links?, links_pay_by_call?, credit_refund_days? }`
- `GET /customers/{id}/agentic/refund-requests` (scope `agentic:read`): Refunds agents have asked for, newest first. `?status=pending`
- `POST /customers/{id}/agentic/refund-requests/{request_id}` (scope `refunds:write`): Approve or deny a refund an agent asked for. Approving runs it as a refund under the deciding key's policies. Body: `{ decision: approve | deny, note? }`
- `POST /customers/{id}/agentic/orders/{session_id}` (scope `agentic:write`): Say where an order has got to; the agent platform is told with a signed order event. The event is recorded before it is sent; one that fails is tried again on the webhook backoff, and `webhook` says how the first attempt went. Body: `{ status, estimated_delivery? }`
- `GET /customers/{id}/agentic/orders/{session_id}` (scope `agentic:read`): Where an order stands: the order as the agent platform was last told it, `session_status`, and `webhook`: how the last order event went (`status` pending, delivered or exhausted, with `attempts`, or `reason: no_webhook`).
### Agentic Commerce Protocol
Called by an agent platform, not by a partner. Authentication is the bearer token the customer issued (`POST /customers/{id}/agentic/credentials`), not an API key. Every call sends `API-Version` (2026-04-17, or an earlier published version) and every POST sends `Idempotency-Key`. Paths, fields, statuses and messages follow the protocol's specification; errors are the protocol's `{ type, code, message, param }`, not Skerry's envelope. The refunds call is a Skerry extension and says so.
- `POST /agentic/{customer_id}/checkout_sessions` (scope `agent_credential`): Create a checkout session. Answers 201 with the session. Body: `{ line_items: [{ id, quantity? }], currency, capabilities?, buyer?, fulfillment_details?, discounts? }`
- `GET /agentic/{customer_id}/checkout_sessions/{checkout_session_id}` (scope `agent_credential`): Retrieve a checkout session.
- `POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id}` (scope `agent_credential`): Update a checkout session: items, buyer, fulfilment details, the fulfilment option chosen. It comes back re-priced. Body: `{ line_items?, buyer?, fulfillment_details?, selected_fulfillment_options?, discounts? }`
- `POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id}/complete` (scope `agent_credential`): Complete with a delegated payment token. A decline leaves the session payable with a message; a bank that wants the buyer to authenticate makes it `authentication_required`. Body: `{ payment_data: { handler_id, instrument: { type, credential: { type, token } } }, buyer?, authentication_result? }`
- `POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id}/cancel` (scope `agent_credential`): Cancel a session that has not completed. 405 afterwards. Body: `{ intent_trace? }`
- `POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id}/refunds` (scope `agent_credential`): A Skerry extension (`xyz.skerry.refunds`), not part of the protocol: ask for a refund of a completed session, within what the business allows agents. 202 when a person must approve, 201 when it was made. Body: `{ amount?, reason? }`
### Universal Commerce Protocol: what a customer sets
Let platforms buy from a customer through the Universal Commerce Protocol, over the same checkout sessions, pricing callback, charging and refunds as agentic checkout. The customer lists the platforms it accepts by the address of their profile and says what its profile publishes; Skerry makes and keeps its signing key. A preview, off by default: every call answers 409 `capability_coming_soon` until it is switched on for the platform, and completing a checkout also needs delegated payment tokens.
- `GET /customers/{id}/ucp/settings` (scope `agentic:read`): What the customer has set for the Universal Commerce Protocol, with `profile_url` (where its business profile is served), `endpoint`, and `signing_key`: the public half of its signing key, never the private half.
- `PUT /customers/{id}/ucp/settings` (scope `agentic:write`): Replace the customer's settings: the platforms it accepts, its profile id at the card processor, the card brands and links it publishes, which wording the handler's environment uses, whether the AP2 mandates extension is on, and (sandbox only) whether unsigned requests are taken. A member left out goes back to its default. The signing key is made on the first save and kept sealed; a deployment with no sealing key answers 503 `platform_unavailable`. Body: `{ trusted_profiles?: [url], business_profile?, card_brands?, links?: [{ type, url }], environment_words?: handler | seller_guide, ap2_enabled?, allow_unsigned? }`
### Universal Commerce Protocol
Called by a platform, not by a partner, and not with an API key. The platform names its profile in `UCP-Agent` and signs each request (RFC 9421, ES256) with a key that profile publishes; the customer must have listed that profile. Paths, fields, statuses and messages follow the protocol's 2026-08-25 version, the only one spoken. What the business decides about a checkout is a 200 with `messages`; a checkout or order that is not there is a 200 with `ucp.status: "error"` and `messages`; protocol errors are `{ code, content }`, not Skerry's envelope. A preview, off by default.
- `GET /ucp/{customer_id}/.well-known/ucp` (no key): The customer's business profile: version, the shopping service and its endpoint, capabilities, the card processor's payment handler, and the customer's public signing key. Public, cacheable for a minute. The business serves or redirects its own `/.well-known/ucp` here.
- `POST /ucp/{customer_id}/checkout-sessions` (scope `ucp_signature`): Create a checkout. Answers 201 with the checkout, priced. Body: `{ line_items: [{ item: { id }, quantity }], currency?, buyer?: { email, first_name, last_name } }`
- `GET /ucp/{customer_id}/checkout-sessions/{id}` (scope `ucp_signature`): Read a checkout. Only the platform that opened it finds it.
- `PUT /ucp/{customer_id}/checkout-sessions/{id}` (scope `ucp_signature`): Replace a checkout: the body is the whole of what the platform sets. It comes back priced again; one that is being completed or is over comes back unchanged with a message. Body: `{ line_items: [{ item: { id }, quantity }], buyer? }`
- `POST /ucp/{customer_id}/checkout-sessions/{id}/complete` (scope `ucp_signature`): Complete with the card processor's payment token, for the checkout's exact total. A decline leaves the checkout `ready_for_complete` with a recoverable message; another handler or credential type is a recoverable `payment_failed`. Must carry the protocol's `Idempotency-Key`, and `ap2.checkout_mandate` under the AP2 mandates extension. Body: `{ payment: { instruments: [{ id, handler_id: "stripe_payments", type: "card", selected: true, credential: { type: "stripe_payment_token", token } }] }, signals?, ap2?: { checkout_mandate } }`
- `POST /ucp/{customer_id}/checkout-sessions/{id}/cancel` (scope `ucp_signature`): Cancel a checkout that is neither completed nor canceled.
- `GET /ucp/{customer_id}/orders/{id}` (scope `ucp_signature`): The order of a completed checkout, as its webhook carries it, for the platform that placed it.
### Paying by the call
Price a resource and let automated clients pay for each call. Skerry builds the 402 for the customer's server in both x402 and the Machine Payments Protocol, checks the payment the client sends back, and settles it on the customer's own merchant account. Skerry runs no facilitator and holds no keys. A preview: every call answers 409 `capability_coming_soon` until it is switched on for the customer.
- `POST /customers/{id}/priced-resources` (scope `machine:write`): Price a resource. A price below a method's minimum, or with a part of a cent, needs `credit_only`. Body: `{ url_pattern, price, currency?, description?, methods?: [stablecoin | agent_token], credit_only?, upstream_url? }`
- `GET /customers/{id}/priced-resources` (scope `machine:read`): The customer's priced resources, with the minimum single payment per method.
- `DELETE /customers/{id}/priced-resources/{resource_id}` (scope `machine:write`): Stop charging for a resource.
- `POST /machine-payments/challenge` (scope `machine:write`): The 402 to send an unpaid request: status, headers and body, in both protocols. Body: `{ resource_id, url?, credit?: { amount } }`
- `POST /machine-payments/verify` (scope `machine:write`): Check and settle the payment a client sent, or draw the call from its credit. Returns whether it is paid and the headers to answer with. Body: `{ resource_id, headers: { "PAYMENT-SIGNATURE"? | "Authorization"? }, url? } | { resource_id, credit_token, idempotency_key }`
- `POST /machine-payments/credits` (scope `machine:write`): Buy prepaid credit for a resource with one payment. Without payment headers, the 402 for that amount; with them, the credit and its token, once. Body: `{ resource_id, amount?, headers? }`
- `GET /machine-payments/credits` (scope `machine:read`): A customer's credits, newest first: bought, balance, refunded, expiry. Never a token. `?customer_id=&resource_id=`
- `GET /machine-payments/credits/{id}` (scope `machine:read`): One credit: bought, balance, expiry.
- `POST /machine-payments/credits/{id}/refund` (scope `refunds:write`): Send back the unused part of a credit, within the customer's policy. Needs an Idempotency-Key. Body: `{ }`
### Hosted addresses and the pay-by-the-call proxy
Public addresses, with no key: the id in the address is the credential, as it is on a hosted checkout page. A checkout session's and a payment link's `share_url` send a browser on to the hosted page and answer a client that asks for JSON (`Accept: application/json`, or the address ending `.json`) with a description it can act on. `/pay/{resource_id}` fronts a priced resource: 402 until paid, then the request is forwarded to the address the business registered. Errors are `{ error: { code, message } }`, or the paying protocol's own on a 402.
- `GET /checkout/{id}` (no key): A checkout session's own address: a redirect for a browser; for a client asking for JSON, the amount, currency, methods, payment instructions, the agentic endpoint when the business has one, and how to pay without a browser.
- `GET /pay-link/{id}` (no key): A payment link's own address: a redirect for a browser; for an automated client, 402 with the pay-by-the-call challenge when the business has switched that on, otherwise 200 with a description.
- `GET /pay/{resource_id}` (no key): The proxy in front of a priced resource: 402 until the request carries a payment or a credit token, then forwarded to the registered address.
- `POST /pay/{resource_id}` (no key): The same, for a request with a body.
### Agents paying
A registered agent pays another merchant. The agent's request is sent to the merchant; when it answers 402, one offer is paid and the request is sent once more. Two ways of paying are built: x402 version 2, the `exact` scheme, in USDC from a wallet whose key the wallet provider holds and whose policy lets it sign that one kind of message; and the Machine Payments Protocol's `stripe` method, with a delegated payment token issued for the challenge's amount and expiry. The payment is decided before anything is signed, as a payout is: freeze, withdrawal allow-list, first payment to a new destination, risk rules, the agent's mandates and budgets, approval. The merchant's host is the payee. A preview: every call answers 409 `capability_coming_soon` until agents paying is switched on and the wallet provider is connected.
- `POST /agents/{id}/wallets` (scope `agents:write`): Create a wallet the agent signs payments from. The key is held by the wallet provider and never returned; a policy there lets it sign typed data for USDC on the wallet's network and nothing else. Sandbox takes `base-sepolia` only, live `base` only. Fund it by sending USDC to its address. Body: `{ network: base | base-sepolia, label? }`
- `GET /agents/{id}/wallets` (scope `agents:read`): The agent's wallets, active and retired, with their addresses.
- `GET /agents/{id}/wallets/{wallet_id}` (scope `agents:read`): One wallet: network, address, state and what its key may sign.
- `DELETE /agents/{id}/wallets/{wallet_id}` (scope `agents:write`): Retire a wallet: nothing is signed with it again. USDC left at its address is not moved, by this or any other operation.
- `POST /agent-payments` (scope `agent-payments:write`): Send the agent's request to a merchant and, when it answers 402, pay one offer and send the request once more. Returns the merchant's status, an excerpt of its body and what was paid. `max_amount` is a ceiling in US cents; the merchant's exact amount is what is paid. Under the agent's mandates and the customer's controls: it may be refused, or held for approval (202). Pays at most once per key. Needs an Idempotency-Key. Body: `{ agent_id, wallet_id?, url, method?, headers?, body?, max_amount, currency: usd, payment_method_id?, external_ref?, external_kind? }`
- `GET /agent-payments` (scope `agent-payments:read`): Attempts to pay merchants, whatever became of them: paid, failed, refused or held. `?agent_id=&status=&external_ref=&limit=`
- `GET /agent-payments/{id}` (scope `agent-payments:read`): One attempt: the merchant, the protocol and network, the amount, the reference, and the authorisation it ran under.
### Rewards and reporting
What the platform pays on balances, and what this project has consumed against its plan.
- `GET /coins` (scope `*`): The stablecoins this project can hold.
- `POST /coins` (scope `*`): Ask for a stablecoin of your own. This records the request and nothing more: no coin is created or minted by it. Body: `{ ticker, name, chains?, loop_type?, note? }`
- `GET /coins/{currency}/rewards` (scope `*`): What a balance in this coin earns.
- `GET /customers/{id}/rewards/{currency}` (scope `customers:read`): What one customer's balance in a coin earned: `balance`, `earned` and `as_of`. Answers 404 where the coin earns nothing for this customer. Figures run two business days behind.
- `GET /usage` (scope `*`): Usage against the plan's limits — the same count a call is refused against.
- `GET /compliance-requests` (scope `compliance:read`): Requests for information about a customer or a payment, with the deadline. `?customer_id=&status=`. Answers 409 `capability_coming_soon` until the platform has been granted them.
- `GET /compliance-requests/{id}` (scope `compliance:read`): One request for information: what was asked, the deadline, and what it holds.
# Praecise Cloud: Getting started
Open Praecise Spaces, sign in with an email code and deploy a project. If you do not have an account yet, talk to us.
[Open the console](https://console.praecise.cloud)
[Talk to us](mailto:contact@praecise.com?subject=Praecise%20Cloud)
In short
To start, get an account from the Praecise team, sign in to the Cloud section of Praecise Spaces with an email code and deploy a project that has a registered source. Praecise handles servers, certificates and releases. If you have no account yet, talk to us.
## Three steps.
3
You
### Get an account.
New accounts are set up with the Praecise team. Write to contact@praecise.com and say what you want to run.
You
### Open Praecise Spaces.
Sign in at spaces.praecise.com with a code sent to your email. You see the projects you have been granted.
You
### Deploy a project.
Choose a project with a registered source and use Deploy. Praecise builds it, places it behind the edge and checks it before it takes traffic.
## What Praecise handles.
3
### Servers and certificates
Nothing to set up.
The machines, routing and certificates behind a site are Praecise's to run.
### Releases
Each deploy is kept.
Recent releases are kept, and any one can be served again.
### Isolation and updates
Handled in the background.
Agents are isolated from each other, and the base is updated with signed images.
## Start an agent app instead.
Praecise Harness starts a new agent app from a folder and runs it locally.
```
npx @praecise/harness init my-app
cd my-app && npm install
npx @praecise/harness dev
```
Node 22 or newer. Place the folder like any other Node app when it is ready.
## Where next.
4
### [Deploying and rolling back](https://praecise.cloud/docs/deploying.md)
What happens on a deploy.
### [Domains](https://praecise.cloud/docs/domains.md)
Serve a site on your own domain.
### [Security defaults](https://praecise.cloud/docs/security-defaults.md)
What is on without you asking.
### [Agents and llms.txt](https://praecise.cloud/docs/agents.md)
How an agent reads this site.
## Next pages.
[Deploying and rolling back](https://praecise.cloud/docs/deploying.md)
[Domains](https://praecise.cloud/docs/domains.md)
[Pricing](https://praecise.cloud/pricing.md)
06
## Praecise accounts are by invitation. Open the console, or talk to us.
[Open the console](https://console.praecise.cloud)
[Talk to us](mailto:contact@praecise.com?subject=Praecise%20Cloud)
New to Praecise Cloud? Accounts are set up with the Praecise team: talk to us, then sign in to the Cloud section of Praecise Spaces with an email code.
Source: https://praecise.cloud/docs/getting-started
# Praecise Cloud: Deploying and rolling back
A deploy turns a folder or repository into a checked release behind the edge. An earlier release can be served again.
[Open the console](https://console.praecise.cloud)
[Talk to us](mailto:contact@praecise.com?subject=Praecise%20Cloud)
In short
A deploy detects and builds the source, places it behind the edge and checks it before it takes traffic. Recent releases are kept, and any one can be served again. This page is for anyone deploying a project.
## What a deploy does.
3
Build
### Detect and build.
Praecise works out whether the source is a static export, a Node app or a container image, and builds it.
Place
### Put it behind the edge.
Routing and certificates are in place before the release serves traffic.
Operate
### Check, then serve.
Health is checked before a release takes traffic, and the release id names what was placed.
## Deploy from the Cloud section of Praecise Spaces.
3
### A registered source
Repository and branch.
A project with a registered repository and branch is deployed from the Deploy button.
### No source registered
The button says so.
A project without a registered source has the button disabled and explains why.
### Environment settings
Kept beside the site.
Settings are kept outside every release, so a redeploy does not disturb them.
## Rolling back.
3
Kept
### Recent releases stay.
Each release is named by its id, and recent ones are kept.
Serve
### Any kept release can be served again.
Praecise waits for the site to be healthy before it takes traffic.
Settings
### A rollback carries no settings.
Environment settings stay beside the site, so they are the same before and after.
## What can be deployed.
4
### A static export
Files served behind the edge.
### A Node app
Run behind the edge, with settings kept outside the release.
### A container image
An app that ships as a container image is run from the image.
### A Harness app
A Node app that is also a chat page, a REST API and an MCP endpoint.
## Next pages.
[Domains](https://praecise.cloud/docs/domains.md)
[Web Apps](https://praecise.cloud/web-apps.md)
[Platform](https://praecise.cloud/platform.md)
06
## Praecise accounts are by invitation. Open the console, or talk to us.
[Open the console](https://console.praecise.cloud)
[Talk to us](mailto:contact@praecise.com?subject=Praecise%20Cloud)
New to Praecise Cloud? Accounts are set up with the Praecise team: talk to us, then sign in to the Cloud section of Praecise Spaces with an email code.
Source: https://praecise.cloud/docs/deploying
# Praecise Cloud: Domains
Attach one or more domains to a site. Until you do, the site is served on an address Praecise gives it.
[Open the console](https://console.praecise.cloud)
[Talk to us](mailto:contact@praecise.com?subject=Praecise%20Cloud)
In short
Attach one or more domains to a site, point each at Praecise once, and a certificate is issued on demand. Until then the site answers on a Praecise address. This page is for owners adding a domain.
## Attach a domain.
3
You
### Name your domains.
Add each domain to the site, for example the bare domain and its www form.
You
### Point each domain at Praecise once.
Change the domain's DNS record as the Cloud section of Praecise Spaces shows.
Praecise
### A certificate is issued.
The certificate is issued on demand, and its state and expiry are tracked.
## Good to know.
3
### An address first
No DNS work to preview.
A site answers on a Praecise address until a domain is attached, so it can be shared for review.
### Many domains
One site, several names.
A site can be served on more than one domain.
### One site per customer
For platforms.
Each customer can be placed under its own name and domain.
## Related.
2
### [Marketing sites](https://praecise.cloud/marketing-sites.md)
Place a static export under your domain.
### [Platforms](https://praecise.cloud/platforms.md)
A site per customer on one account.
## What Praecise handles.
3
### Routing
In front of every site.
Domains, certificates and routing sit in front of every workload.
### Certificates
Issued on demand.
Certificate state and expiry are tracked for each domain.
### The first address
No DNS work.
A new site is reachable on a Praecise address straight away.
## Next pages.
[Deploying and rolling back](https://praecise.cloud/docs/deploying.md)
[Marketing Sites](https://praecise.cloud/marketing-sites.md)
[Platforms](https://praecise.cloud/platforms.md)
06
## Praecise accounts are by invitation. Open the console, or talk to us.
[Open the console](https://console.praecise.cloud)
[Talk to us](mailto:contact@praecise.com?subject=Praecise%20Cloud)
New to Praecise Cloud? Accounts are set up with the Praecise team: talk to us, then sign in to the Cloud section of Praecise Spaces with an email code.
Source: https://praecise.cloud/docs/domains
# Praecise Cloud: Security defaults
What is on without you asking, and where you set the limits yourself.
[Open the console](https://console.praecise.cloud)
[Talk to us](mailto:contact@praecise.com?subject=Praecise%20Cloud)
In short
Access is per project, settings stay outside releases, each agent runs in an isolated computer and updates are signed. This page lists the defaults and the limits you set yourself.
## On by default.
4
### Access per project
People see what they are granted.
The Cloud section of Praecise Spaces shows a person only the projects they have been granted.
### Settings outside releases
A rollback carries none.
Environment settings are kept beside the site, never inside a release.
### Isolated agents
One computer per agent.
A Silo reaches only the services its owner connects to it, and the real keys stay outside.
### Signed updates
No tampered image.
The base replaces a signed image and refuses an unsigned or tampered update.
## Limits you set.
3
Tools
### A guard decides what runs.
In a Harness app, guard.ts decides which tool calls are made, and a refusal goes back to the model as text.
Payments
### Scoped keys.
Each agent has its own Skerry key, with spend limits, approvals and revocation.
People
### Approvals.
A workflow can stop at an approve step for a person.
## Read the claims.
2
### [Security](https://praecise.cloud/security.md)
What the platform inherits from the open base.
### [Furcate on GitHub](https://github.com/furcateresearch/furcate)
Apache-2.0
The open base is public.
## Questions this answers.
4
### Can an agent reach the network?
A Silo reaches only what its owner connects to it.
### Who can see a project?
Only the people you grant access to it.
### Can an update be forged?
The base refuses unsigned or tampered updates.
### What survives a rollback?
Settings stay beside the site, not in the release.
## Next pages.
[Security](https://praecise.cloud/security.md)
[Deploying and rolling back](https://praecise.cloud/docs/deploying.md)
[Agent Stack](https://praecise.cloud/agent-stack.md)
06
## Praecise accounts are by invitation. Open the console, or talk to us.
[Open the console](https://console.praecise.cloud)
[Talk to us](mailto:contact@praecise.com?subject=Praecise%20Cloud)
New to Praecise Cloud? Accounts are set up with the Praecise team: talk to us, then sign in to the Cloud section of Praecise Spaces with an email code.
Source: https://praecise.cloud/docs/security-defaults
# Praecise Cloud: Agents and llms.txt
An agent that arrives knowing nothing can read a map of this site and a plain-markdown copy of every page.
[Open the console](https://console.praecise.cloud)
[Talk to us](mailto:contact@praecise.com?subject=Praecise%20Cloud)
In short
An agent can read llms.txt, fetch any page as markdown and use llms-full.txt for all the text at once. A Harness app publishes its own llms.txt and ai.json. This page is for people building or onboarding agents.
## How an agent starts.
3
Read
### Fetch llms.txt.
It lists every page and product in groups, with a link to the markdown version of each.
Read
### Fetch a page as markdown.
Add .md to any page address, or use llms-full.txt for the text of every page at once.
Act
### Use the Cloud section of Praecise Spaces or Harness.
A person or an agent with access to a project can deploy it from the Cloud section of Praecise Spaces.
## Point an agent at the site.
Two addresses are enough to begin.
```
curl https://praecise.cloud/llms.txt
curl https://praecise.cloud/llms-full.txt
```
A Harness app serves its own llms.txt and ai.json, so another agent can read what it does.
## What a Harness app offers other agents.
3
### MCP endpoint
Tools by protocol.
Every Harness app is also an MCP endpoint.
### A2A peer
Agent to agent.
Another agent can call the app as a peer.
### Self-description
llms.txt and ai.json.
The app publishes what it does.
## Where to start.
3
### [llms.txt](https://praecise.cloud/llms.txt)
The map of this site for an agent that arrived knowing nothing.
### [Praecise Harness](https://github.com/Praecise/praecise-harness)
Apache-2.0
The open framework for building an agent.
### [Tools](https://praecise.cloud/tools.md)
The same actions for a person and for an agent.
## Next pages.
[Tools](https://praecise.cloud/tools.md)
[Frameworks](https://praecise.cloud/frameworks.md)
[Agent Stack](https://praecise.cloud/agent-stack.md)
06
## Praecise accounts are by invitation. Open the console, or talk to us.
[Open the console](https://console.praecise.cloud)
[Talk to us](mailto:contact@praecise.com?subject=Praecise%20Cloud)
New to Praecise Cloud? Accounts are set up with the Praecise team: talk to us, then sign in to the Cloud section of Praecise Spaces with an email code.
Source: https://praecise.cloud/docs/agents
# Praecise account: Quickstart: a first call
Five minutes: get a token, ask the account service who it acts as, and list
the account's domains. For signing people in to a product, start with
[tutorial.md](tutorial.md) instead.
## 1. Get a token
A person makes it, once, and it is shown once:
1. Sign in at `https://accounts.praecise.com/account`.
2. Under **Service tokens**, name the token (for example `my-service, production`) and press **Mint**.
3. Copy the value. It starts with `prc_` and is stored only as a hash, so it
cannot be shown again; revoke it there and mint another if it is lost.
A token acts as that one account and no other. A program cannot mint a token:
that takes a signed-in person.
## 2. Make a first call
```sh
export PRAECISE_TOKEN=prc_…
curl -s https://accounts.praecise.com/api/me \
-H "Authorization: Bearer $PRAECISE_TOKEN"
```
## 3. Read the answer
```json
{ "account": { "id": "3b1f6c0e-…", "email": "you@example.com" } }
```
`id` is the account, a UUID that never changes. A `401` means the token was
not accepted (mistyped, or revoked); every error is JSON with an `error`
sentence and sometimes a `detail`.
## 4. Do something with it
List the account's domains and whether each is proven:
```sh
curl -s https://accounts.praecise.com/api/domains \
-H "Authorization: Bearer $PRAECISE_TOKEN"
```
Claim a domain, then make a mailbox on it once it is verified:
```sh
curl -s https://accounts.praecise.com/api/domains \
-H "Authorization: Bearer $PRAECISE_TOKEN" -H "Content-Type: application/json" \
-d '{"domain": "example.com"}'
curl -s https://accounts.praecise.com/api/mailboxes \
-H "Authorization: Bearer $PRAECISE_TOKEN" -H "Content-Type: application/json" \
-d '{"localpart": "hello", "domain": "example.com", "name": "Hello", "kind": "human"}'
```
Every operation is in the OpenAPI document at
`https://accounts.praecise.com/openapi.json`, and described in
[api.md](api.md).
# Praecise account: Tutorial: sign in with the Praecise account
From nothing to a product that signs people in through Praecise with no
sign-in page of its own, confirms a sensitive action with a passkey, and
signs out everywhere. About thirty minutes. The finished code is one file,
[`examples/sign-in/server.mjs`](../examples/sign-in/server.mjs), which the
account service's own tests run against the service, so every step below is
known to work. The reference for each step is [sign-in.md](sign-in.md).
You need: Node 20 or later, the `jose` package, and a client at the account
service (step 0).
## 0. Be a client
A product is an entry in the account service's client list and a secret. Ask
whoever runs the account service for an entry with:
- an id, for example `my-product`;
- your host, for example `https://my-product.example`;
- the path the code comes back to, for example `/auth/praecise/callback`;
- the path you take logout tokens at, for example
`/auth/praecise/backchannel-logout`.
The secret is 32 random bytes as base64url, set on both sides and written
nowhere else:
```
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
```
Then run the example as your product:
```
CLIENT_ID=my-product CLIENT_SECRET= BASE_URL=https://my-product.example \
node examples/sign-in/server.mjs
```
## 1. Send the person to sign in
There is no sign-in page in your product. When somebody opens an address
that needs them signed in and has no session of yours, send the browser to
the account service at once, with a PKCE challenge:
```js
const state = random(16);
const verifier = random();
pending.set(state, { verifier, nonce });
const url = new URL("https://accounts.praecise.com/auth");
url.search = new URLSearchParams({
client_id: clientId,
response_type: "code",
redirect_uri: `${baseUrl}/auth/praecise/callback`,
scope: "openid email profile",
state,
nonce,
code_challenge: sha256(verifier).toString("base64url"),
code_challenge_method: "S256",
}).toString();
res.writeHead(302, { Location: url.toString() }).end();
```
Keep `state`, `verifier` and `nonce` on your side until the browser returns.
The `redirect_uri` must be the registered one, character for character.
## 2. Take the code back
The browser returns to your callback with `code` and your `state`. Swap the
code for tokens, server to server, and verify the ID token:
```js
const answer = await fetch("https://accounts.praecise.com/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: `${baseUrl}/auth/praecise/callback`,
code_verifier: started.verifier,
}),
});
const { id_token } = await answer.json();
const keys = createRemoteJWKSet(new URL("https://accounts.praecise.com/jwks"));
const { payload: claims } = await jwtVerify(id_token, keys, {
issuer: "https://accounts.praecise.com",
audience: clientId,
});
if (claims.nonce !== started.nonce) throw new Error("not for this sign-in");
```
Now make your own session, bound to `claims.sub`:
```js
sessions.set(id, { sub: claims.sub, email: claims.email, emailVerified: claims.email_verified === true });
```
`sub` never changes; the address can. If you already hold a record for that
address from before, link it to this `sub` only when `email_verified` is
true; otherwise tell the person to confirm their address at
`https://accounts.praecise.com/account#email` and come back.
## 3. No page of your own in between
Praecise is the one gate. Whoever is signed in there comes straight back from
the request of step 1 with a code and never sees a page; anybody else signs
in, or makes their account, there. So the product's protected address does
only this:
```js
if (url.pathname === "/app") {
if (!session) return startSignIn(res); // the ordinary request, at once
return showTheProduct(res, session);
}
```
and the callback sends a refusal to the public page, which sends nobody
anywhere by itself. That, and not a sign-in page, is what prevents a loop:
```js
if (query.get("error")) return redirect(res, "/");
```
Make your own record of the person quietly the first time their `sub`
arrives. Decide what they may do (role, plan, membership); never whether they
may sign in.
## 4. Sign out through Praecise
Signing out of your product is signing out of Praecise. End your session,
then:
```js
const url = new URL("https://accounts.praecise.com/session/end");
url.search = new URLSearchParams({
client_id: clientId,
post_logout_redirect_uri: `${baseUrl}/`, // exactly this, slash included
}).toString();
```
## 5. End your sessions when told
When the person signs out anywhere else, the account service posts a signed
logout token to your registered path. Verify it and end every session you
hold for that `sub`:
```js
const { payload, protectedHeader } = await jwtVerify(token, keys, { issuer, audience: clientId, maxTokenAge: "2 minutes" });
if (protectedHeader.typ !== "logout+jwt") throw new Error("not a logout token");
if (!payload.events?.["http://schemas.openid.net/event/backchannel-logout"]) throw new Error("not a logout");
if (!payload.sub || payload.nonce !== undefined) throw new Error("no subject, or a nonce");
if (seen.has(payload.jti)) throw new Error("seen before");
seen.add(payload.jti);
for (const [id, s] of sessions) if (s.sub === payload.sub) sessions.delete(id);
res.writeHead(204).end();
```
Answer `204` (or `200`) when done and `400` when the token is refused. You
receive one for a sign-out you started yourself too.
## 6. Confirm something sensitive with a passkey
Before a payment, a key being shown, a member being removed: ask the person
to confirm with their passkey, and tie the answer to the action with the
nonce.
```js
const nonce = sha256(`${action}:${random(8)}`).toString("hex");
// the request of step 1, plus:
// acr_values=phr max_age=0 prompt=login nonce=
```
In the callback, accept it only if it is the same person, with a passkey,
just now:
```js
const fresh = Date.now() / 1000 - claims.auth_time < 120;
if (claims.sub !== session.sub || claims.acr !== "phr" || claims.nonce !== started.nonce || !fresh) refuse();
```
If your API is a separate service, pass it the ID token and let it make the
same checks itself against `/jwks`, with `aud` equal to your client id.
## Guides
**Retiring your own sign-in.** Praecise sign-in set up on live and proven by
a real member first; your passwords, emailed codes and reset pages removed
only after, in one release, with nothing left behind. A member's existing
record is linked to their `sub` at their first Praecise sign-in, when
`email_verified` is true.
**Invitations and "Create account".** A Praecise account needs an invitation,
and Praecise is the only place one is asked for, issued or used. Your product
has no invitation code, no list of invited addresses and no request form.
"Create account" in your product is a link to
`https://accounts.praecise.com/signup?to=`: the account is
made there and the person lands back at your product, signed in. Membership
of a team or an organisation is yours to keep; it decides what a person may
do.
**Living on two hosts, or moving to a new one.** One client can have several
hosts: the account service's setting for your client lists them, each gets
the return address and the sign-out address, and the first gets the logout
tokens. A move is: add the new host, release your product there, remove the
old host. Each host keeps its own session cookie; the Praecise session makes
the second one silent.
**Your "Your account" page.** Name, address, password, passkeys and devices,
sign-in history and "sign out everywhere" are at
`https://accounts.praecise.com/account`. Link there; keep only your own
preferences.
**Calling the shared services from your server** (domains, mailboxes): a
service token, minted by a person on their account page and sent as
`Authorization: Bearer`. See [api.md](api.md).
```
curl -s https://accounts.praecise.com/api/me -H "Authorization: Bearer $PRAECISE_TOKEN"
```
**A machine, an agent, an assistant.** See [agents.md](agents.md).
## When it does not work
| You see | Cause |
|---|---|
| A page saying the redirect address is not registered | `redirect_uri` differs from the registered one (host, path, a trailing slash) |
| `invalid_request` about `code_challenge` | PKCE missing; it is compulsory |
| `invalid_client` at `/token` | wrong secret, or the client is not switched on (its secret is not set at the account service) |
| `invalid_grant` at `/token` | the code was used already, is older than ten minutes, or the `code_verifier` or `redirect_uri` differs from step 1 |
| `login_required` on a request that should show the sign-in page | you sent `prompt=none` |
| The browser goes round between your product and the account service | your public page, or your callback on an `error`, starts the request again; only a protected address may |
| The ID token fails verification | compare `iss` exactly, `aud` with your client id, and fetch `/jwks` again if the `kid` is unknown |
| The page after sign-out is the account service's, not yours | `post_logout_redirect_uri` is not exactly your host plus `/` |
| No logout token arrives | your entry names no path for it, your endpoint is not reachable from the account service, or it did not answer 200 or 204 (the account service's log names the product) |
# Praecise account: Signing people in
`https://accounts.praecise.com`
For whoever builds a Praecise product. Praecise is the one gate: invitation,
sign-up, sign-in, the second step and sign-out exist here and nowhere else. A
product has no sign-in page, no sign-up page, no invitation code and no
request-an-invitation form of its own. It sends the person here and is told
who they are; whoever holds a Praecise account and arrives at a product is
in, and the product decides only what they may do there. This page is the reference for that; [tutorial.md](tutorial.md) builds it
step by step with working code; machines, agents and assistants are in
[agents.md](agents.md); tokens for an API, and the shared services, are in
[api.md](api.md).
The service is an OpenID Connect provider. Everything below is also in its
metadata at `/.well-known/openid-configuration`, which a library can read by
itself.
| | |
|---|---|
| Issuer | `https://accounts.praecise.com` (compare exactly) |
| Authorization | `GET /auth` |
| Token | `POST /token` |
| Keys | `GET /jwks` |
| Userinfo | `GET /me` |
| End session | `GET /session/end` |
| Signing | RS256; each key in the JWKS has a `kid` |
## Becoming a client
Clients are a fixed list in `src/clients.js`: adding a product is one entry
there (its id, its base address, the path the code is returned to, optionally
a path for logout tokens and a theme) and one secret. A client exists only
while its secret setting is present; the service reads its settings at start.
- **The secret** is 32 random bytes as base64url, the same value on both
sides, set in each deployment's settings and nowhere else.
- **The return address** is the product's host plus the path in its entry,
compared exactly. **The address after sign-out** is the host plus `/`,
compared exactly, slash included.
- **More than one host.** `_CALLBACK_BASE` (the secret's name without
`_SECRET`) may list several hosts separated by commas, each as scheme and
host only. Every listed host gets the return address and the sign-out
address; the first listed is where logout tokens are sent. This is how a
product lives on two hosts, or moves from one to another, as one client.
## Signing in
Send the browser to:
```
GET https://accounts.praecise.com/auth
?client_id=
&response_type=code
&redirect_uri=
&scope=openid%20email%20profile
&state=
&code_challenge=
&code_challenge_method=S256
```
PKCE with S256 is compulsory for every client; a request without it is
refused. There is no implicit flow. `nonce` is optional and comes back
unchanged in the ID token.
The person signs in here if they are not already (password, then their
passkey if they hold one), and the browser returns to your address with
`code`, your `state`, and `iss`. Check `state`, then:
```
POST https://accounts.praecise.com/token
Authorization: Basic base64(:)
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=&redirect_uri=&code_verifier=
```
The answer holds `id_token`, an `access_token` for `/me`, and, when
`offline_access` was asked for, a `refresh_token`. Verify the ID token's
signature against `/jwks`, and its `iss`, `aud` (your client id, a single
string) and `exp`. Then make your own session. A code is good for ten minutes
and once.
### What the ID token says
The claims are in the ID token itself; `/me` gives the same for whoever
prefers to ask.
| Claim | Scope | Meaning |
|---|---|---|
| `sub` | `openid` | The account: a UUID that never changes. **Bind your own record of the person to this**, not to the address |
| `email` | `email` | The account's address |
| `email_verified` | `email` | `true` only when the person has shown they read that mailbox (a code at sign-up, at recovery, or confirmed once later). It can be `false` for an account made before sign-up sent a code. **Do not link an existing record of yours to a Praecise identity by address when it is `false`**; say in one sentence that the address has to be confirmed at `https://accounts.praecise.com/account#email` |
| `name` | `profile` | What the person calls themselves; absent until they set one |
| `amr` | `openid` | How they signed in, every method used: `pwd`, `otp` (a password set through the mailbox), and for a passkey `hwk` (bound to one device) or `swk` (synced), with `user` and `mfa` |
| `acr` | `openid` | `phr` when a passkey was verified in this sign-in; otherwise absent |
| `auth_time` | | When the person last signed in or confirmed; present when the request carried `max_age` or `prompt=login` |
| `nonce` | | Yours, unchanged |
| `sid` | | Only for a client that asks to be told which session ended; none does today |
Lifetimes: the Praecise session thirty days; an ID token and an access token
one hour; a refresh token is replaced as it is used and cannot be kept alive
beyond a year.
## One session, and no sign-in page of your own
Signed in to the Praecise account means signed in to every product. So when
somebody needs to be signed in and has no session of yours, send the browser
to `/auth` with the ordinary request above, at once, with no page of yours in
between. A person signed in at Praecise comes straight back with a code and
never sees a page; anybody else signs in, or makes their account, here and
then comes back. Make your own record of the person quietly on their first
arrival.
What keeps this from looping is your public page, not a sign-in page: after a
sign-out, or when the request comes back with an `error` (the person
declined), show the public page, which sends nobody anywhere by itself.
| What you link to | Address |
|---|---|
| "Sign in" / "Open" | your own protected address, which starts the ordinary request |
| "Create account" | `https://accounts.praecise.com/signup?to=` (add `&invite=` to fill the code in). The account is made here and the person lands back at your product. The sign-in page reached by the ordinary request has the same way in, so "Sign in" alone also serves |
| "Request an invitation" | no address yet: write the words without a link. It will be one page here, for every product |
| "Your account" | `https://accounts.praecise.com/account` |
**Looking without asking** (`prompt=none`) is the same request for when you
must not show Praecise's page: a public page that wants to say "Open" where
it would say "Sign in" (or use `/session/check`, below), or a check that your
session has not outlived the Praecise one.
| Comes back | Means |
|---|---|
| `code` | They are signed in at Praecise |
| `error=login_required` | Nobody is signed in in this browser (or `max_age` has passed) |
| any other `error` | Something is wrong with the request; do not loop |
To keep a session of yours from outliving the Praecise one, either take
logout tokens (below) or look again from time to time; do both if a missed
sign-out would matter.
## Asking for a passkey
A person who holds a passkey is asked for it after their password at every
new sign-in, and the ID token then has `acr: "phr"`. A product that needs to
know the person is at their device **now**, before something sensitive, asks
again:
```
&acr_values=phr&max_age=0&prompt=login&nonce=
```
They are asked for a passkey even though they are signed in; only a passkey
of the account already signed in is accepted; a person who holds none is led
through adding one first. Check in the ID token that comes back: `acr` is
`phr`, `auth_time` is recent, `nonce` is yours, `sub` is who you expect. With
`prompt=none` the answer is `login_required` unless the session already used
a passkey.
There is no sign-in by passkey alone, and a password alone never yields
`acr: "phr"`.
## Signing out
**From your product.** End your own session, then send the browser to:
```
GET https://accounts.praecise.com/session/end
?client_id=
&post_logout_redirect_uri=
```
The Praecise session ends, with no second question for our own clients, and
the browser comes back to you. A person who signs out of one product has
signed out of Praecise.
**Being told of a sign-out made elsewhere** (OpenID Connect Back-Channel
Logout). A product that names a path for it in its entry is sent, server to
server:
```
POST
Content-Type: application/x-www-form-urlencoded
logout_token=
```
when a session that had signed in to it ends: at `/session/end` from any
product, from the account page, or by "Sign out everywhere". Verify, in this
order, and refuse on any failure:
1. the signature, against `/jwks`; the header's `typ` is `logout+jwt`;
2. `iss` is the issuer and `aud` is your client id;
3. `events` has the member `http://schemas.openid.net/event/backchannel-logout`;
4. `iat` is recent and `jti` has not been seen;
5. there is a `sub` and no `nonce`.
Then end **every** session you hold for that `sub`, and answer `200` or `204`.
You are sent one for a sign-out you started yourself as well. Delivery is
tried once: a product that cannot be reached is named in the service's log and
keeps its session until it next looks.
## What the person manages here, not in your product
`https://accounts.praecise.com/account`: their name, their address and its
confirmation (`#email`), their password, their passkeys and devices
(`#passkeys`), their sign-in history, and signing out everywhere. A product's
"Your account" links there for all of it and keeps only its own preferences.
Accounts are made by invitation, here: `https://accounts.praecise.com/signup`
asks for the code, and `?invite=` fills it in. A product has no
invitation to have an account and no check of invited addresses; what it may
have is membership (who belongs to which team or organisation), which decides
what a person may do, never whether they may sign in.
## For a public page: is this browser signed in?
`GET https://accounts.praecise.com/session/check`, with credentials, from one
of our own origins (the list is in `src/server.js`), answers
`{"signedIn": true|false}` and nothing about who. It lets a public page say
"Open" where it would say "Sign in".
## The sign-in pages in a product's own brand
A client that names a theme (`src/themes.js`) gets the same pages with its
mark, its wording and its tokens; the flow is not changed by it. Without one
the pages are Praecise's own.
## Not a person in a browser
A machine with no browser (device grant), an agent acting alone (client
credentials), an outside assistant that registers itself or is described by a
metadata document, tokens bound to a key (DPoP), and access tokens for an API
with claims from that API's own hook: all in [api.md](api.md).
## Before a product goes live
- Its own sign-in is gone only after Praecise sign-in is set up on live and a
real member has come in through it.
- It binds to `sub`, and treats `email_verified: false` as above.
- It has no sign-in, sign-up or invitation page: its protected addresses start
the ordinary request, and its public page is where a refusal or a sign-out
lands.
- Its sign-out goes through `/session/end`.
- It either takes logout tokens or looks again on a schedule.
# Praecise account: Machines, agents and assistants
`https://accounts.praecise.com`
For anything that relies on the Praecise account and is not a person in a
product's browser tab: a server being enrolled, an agent that runs
unattended, an assistant acting for a person, an API that accepts this
service's tokens. A product signing people in is in [sign-in.md](sign-in.md);
the shared services and the detail of tokens for an API are in
[api.md](api.md).
Everything here is discoverable without this page: the metadata is at
`/.well-known/openid-configuration` (and, with the same content,
`/.well-known/oauth-authorization-server`) and the keys at `/jwks`.
## Which one are you
| You are | You use | A person is involved |
|---|---|---|
| A program acting as one account on the shared services (domains, mailboxes) | a service token, sent as `Authorization: Bearer` to `/api` ([api.md](api.md)) | once, to mint the token on their account page |
| A machine with no browser that should belong to an account | the device grant | once, to approve it on another device |
| An agent that runs with nobody present | the client credentials grant, for one API | never; the API decides what the agent may do |
| An assistant or tool acting for a person (an MCP host, a command line) | authorization code with PKCE, as a client described by a metadata document or registered by itself | each time, on a consent page |
| An API that accepts these tokens | the JWKS, and optionally a claims hook | no |
## A machine with no browser: the device grant (RFC 8628)
The client is `furcate-node`, public, with no secret.
1. `POST /device/auth` with `client_id=furcate-node&scope=openid`. The answer
holds `device_code`, `user_code` (eight letters, shown as `XXXX-XXXX`),
`verification_uri` (`/device`), `verification_uri_complete`, `interval`
and `expires_in` (five minutes).
2. Optionally say what is being claimed, so the person approving sees it and
not only a client name: `POST /device/enrolment` with JSON
`{"user_code", "node_id", "site", "site_addr", "machines": [{"node_id", "name", "attested", "administers"}]}`
(at most 64 machines). It grants nothing; it only fills the approval
screen for that code.
3. Show the person the code and the address. They open it on a device with a
browser, sign in, and approve what the screen lists.
4. Poll `POST /token` with
`grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=…&client_id=furcate-node`,
no faster than `interval`. `authorization_pending` means not yet;
`expired_token` or `access_denied` means start again.
## An agent with nobody present: client credentials
An agent client is an entry in the service's `AGENT_CLIENTS` setting (an id
and a secret of at least 32 characters; optionally `dpop_required`). It may
ask for a token for one API, and only an API that has a claims hook, because
the API, not this service, says what that agent is and may do:
```
POST /token
Authorization: Basic base64(:)
grant_type=client_credentials&resource=&scope=&
```
The token is refused unless the API's hook approves this client for it. It
lasts ten minutes at most. No other kind of client may use this grant.
## An assistant acting for a person
Authorization code with PKCE (S256), as a public client, identified in one of
two ways:
- **A metadata document.** The `client_id` is an HTTPS URL and the JSON
document there is the registration (the form the MCP authorization
specification asks for). It is fetched over HTTPS from a public host,
without following redirects, at most 5 kB within 2.5 seconds; its
`client_id` must be the URL it was fetched from; it must name the client and
its redirect addresses; a loopback redirect address may use any port.
- **Registering itself** (RFC 7591): `POST /reg` with its metadata. The answer
holds the `client_id` and a token with which it can read, change and delete
its own registration.
Either way the person signs in and sees a consent page naming the assistant
(for a document, its host) and exactly what it asked for; nothing is granted
that was not shown. One that asks only who the person is (`openid`, `email`,
`profile`) is shown a plain page listing what it will be told; within one
Praecise sign-in it is asked once, and a silent request (`prompt=none`) before
that is answered `consent_required`. Such a client never gets the client credentials grant.
A token is for exactly one API: name it with `resource` on the authorization
request (RFC 8707), ask for that API's scopes, and send the token only there.
What the API's token carries, how long it lasts and how it is refreshed is in
[api.md](api.md). Add `offline_access` to stay connected; a refresh token for
an API with a hook is replaced at every use, and a second use of an old one
ends the grant.
## Binding a token to a key (DPoP, RFC 9449)
Any client may send a DPoP proof with its token request (ES256 or EdDSA). The
access token then carries the key's thumbprint in `cnf.jkt`, and the API asks
for a proof with every call. A client marked `dpop_required` is refused a
token without one.
## Ending a token
- `POST /token/revocation` ends a refresh token and its grant. A JWT access
token cannot be revoked; it ends when it expires (fifteen minutes at most
for an API with a hook).
- `POST /token/introspection` says whether a token is active, for an API that
cannot verify a JWT itself.
- A person ends everything a browser holds with "Sign out everywhere" on
their account page; service tokens, enrolled machines and connected
assistants are ended one by one there, not by that.
## An API that accepts these tokens
Verify the JWT against `/jwks`: `iss` is exactly the issuer, `aud` is exactly
your own address, `exp` has not passed, and, when the token is bound,
the DPoP proof matches `cnf.jkt`. To decide yourself what a token for you may
say (which account at your service, which permissions), offer a claims hook:
this service asks it at every issuance and refresh and issues nothing when it
refuses, fails or times out. The hook's request, signature and answers are in
[api.md](api.md).
An ID token from a fresh passkey confirmation can serve as evidence for one
action: the product sends `nonce` tied to the action, and the API checks
`iss`, `aud` (the product's client id), `nonce`, `auth_time`, `acr: "phr"`,
`amr` and `sub` itself ([sign-in.md](sign-in.md), "Asking for a passkey").
## What does not exist
- No password grant, no implicit flow, no sign-in by emailed code, no sign-in
by passkey alone.
- No way for a program to make an account: accounts are made by a person,
with an invitation and a code sent to their mailbox.
- No way for a program to mint its first service token: a person does that on
their account page.
# Praecise account: The shared services and tokens for an API
`https://accounts.praecise.com/api`
Domain verification and mailbox provisioning are account-level facts. Every
product in the estate needs them, and none of them should learn to talk to the
mail service directly — that is how one estate ends up with four
domain-verification flows and a person proving the same domain four times.
There is no `/v1`. A version in a path is a promise to run two of everything.
Signing a person in to a product (the sign-in request, the ID token's claims,
silent sign-in, asking for a passkey, sign-out and logout tokens) is in
[sign-in.md](sign-in.md). This page is the shared services and the tokens for
an API.
## Authenticating
Two ways in, different in kind.
**The gateway session cookie** (`praecise_gateway`), for a browser that is
already signed in. Acts as that person.
**A service token**, for a product calling server to server:
```
Authorization: Bearer prc_…
```
A token acts as exactly one account — the account it was issued for. It is
stored only as a SHA-256, so the table is a list of account ids and dates
rather than a set of working credentials, and a leak costs one account rather
than the estate.
Everything answers JSON. `401` means neither credential was accepted.
## Getting a token
A person signs in to `accounts.praecise.com` and mints one:
```
POST /api/tokens {"name": "rivier, production"}
→ 201 {"name": …, "token": "prc_…", "note": …}
GET /api/tokens → 200 {"tokens": [{id, name, created, lastUsed}]}
DELETE /api/tokens/:id → 204 (idempotent)
```
The value is shown once. Minting and revoking accept the session cookie only:
a token that could mint its successors would make revoking the original
pointless, so presenting a bearer here is a `403`, not a `401`.
## The account
```
GET /api/me → {"account": {"id": …, "email": …}}
```
The cheapest way for a caller to check that a token still works.
### For a product's server: is this a Praecise account?
A product that acts on accounts on another product's word (the Praecise Cloud
console, whose project API lets Rivier act for an account) must not take that
word for it:
```
GET /api/accounts/lookup?email=person@example.com (or ?id=)
Authorization: Basic base64(:)
→ 200 {"account": {"id": "…", "email": "person@example.com", "confirmed": true}}
→ 404 {"error": "no Praecise account has that address"}
```
The credential is the product's own client id and secret, as at the token
endpoint; a person's service token or session is refused here (`401`), and so
is a product not marked for it in the client list (`403`). Give exactly one
of `email` and `id` (`400` otherwise), and prefer `id` (the `sub` of ID
tokens) whenever you hold it: it cannot be guessed and does not change when
an address does. `confirmed` says whether the person has proven the mailbox;
when it is false, do not act for the account on the strength of the address.
At most 120 calls a minute for each client (`429` with `Retry-After`), and
every call is logged with who asked and the outcome, never the address.
## Domains
```
GET /api/domains
POST /api/domains {"domain": "example.com"}
POST /api/domains/:domain/verify
```
`records` is what still has to be published. An empty list on an unverified
domain means the records are there and DNS has not caught up.
If Praecise holds the registrar for the domain, `POST /api/domains` publishes
the ownership record and checks it in the same call — `published: true`, and
usually `verified: true` with it. Otherwise the records come back for somebody
to publish, which is the honest answer rather than a worse one.
## Mailboxes
```
GET /api/mailboxes
POST /api/mailboxes {"localpart": "alerts", "domain": "example.com",
"name": "Alerts", "kind": "machine"}
GET /api/mailboxes/:email
```
`kind` is `human`, `agent` or `machine` — a server, a robot, an autonomous
machine. It is a Praecise fact and is kept here: the mail service's own
vocabulary is `human` and `send-only`, derived from whether a mailbox has an
inbox, and it discards anything else it is sent. That is reasonable of it —
delivery does not care who is at the other end — but it is what makes a record
readable afterwards. A thread between two addresses is two strangers; a thread
between a machine and an operator is an outage being reported.
`409` means the domain is on the account but not verified yet. `404` means it
is not on the account at all, and is also the answer for a mailbox that
belongs to somebody else — there is no way to learn from here whether an
address exists elsewhere.
## Mail hosts
Clients connect to **`mail.praecise.com`**: IMAP on 993, submission on 465,
both with a certificate for that name. `_imaps._tcp` and `_submissions._tcp`
SRV records say the same, and `autoconfig.praecise.com` answers for clients
that look there.
Inbound delivery still has `mail.furcate.foundation` as the MX. That is not
drift: the MTA-STS policy at `mta-sts.praecise.com` is generated by the mail
server from its own `Hostname` and is served in `mode: enforce`, so an MX
naming anything the policy does not list would be skipped by every sender that
honours it. The MX moves when that `Hostname` does, which is a change on the
gateway host itself.
## Access tokens for a resource
Apart from the shared services above, this host is an OAuth authorization
server: it issues access tokens that another API accepts. What a client or a
resource server needs is in the metadata, at
`/.well-known/openid-configuration` and, with the same content,
`/.well-known/oauth-authorization-server`:
| field | what it is for |
|---|---|
| `issuer` | the `iss` of every token; a resource server compares it exactly |
| `jwks_uri` | the keys access tokens and ID tokens are signed with |
| `id_token_signing_alg_values_supported` | the signing algorithms; access tokens use the same key and algorithm as ID tokens (RS256), and each key in the JWKS names its `alg` |
| `dpop_signing_alg_values_supported` | `ES256`, `EdDSA` |
| `revocation_endpoint`, `introspection_endpoint` | see below |
| `client_id_metadata_document_supported` | `true` |
| `acr_values_supported` | `phr` |
| `authorization_response_iss_parameter_supported` | `true` (RFC 9207) |
A token is for exactly one resource. The client names it with `resource`
(RFC 8707) on the authorization request, and the token's `aud` is that value.
### The Skerry API
Resource `https://api.skerry.xyz` (`SKERRY_RESOURCE`). The access token is a
JWT with header `typ: at+jwt`:
| claim | value |
|---|---|
| `iss` | this issuer |
| `aud` | `https://api.skerry.xyz` |
| `sub` | the account id; for the client credentials grant, the client id |
| `client_id` | the client |
| `scope` | Skerry scope names, space separated; never `*` |
| `iat`, `exp` | at most 900 seconds apart |
| `jti` | unique per token |
| `skerry_partner` | the Skerry account the token acts in |
| `skerry_mode` | `sandbox` or `live` |
| `skerry_agent` | the registered agent, when the client acts as one |
| `cnf.jkt` | the key the token is bound to, when DPoP was used |
The three `skerry_` claims and the final scope come from Skerry's claims hook.
The scope names are listed in `src/resources.js`, from the Skerry API's
reference.
**A person, through an application** (authorization code with PKCE):
```
GET /auth?response_type=code
&client_id=https://host.example/oauth/client.json
&redirect_uri=http://127.0.0.1:3000/callback
&resource=https://api.skerry.xyz
&scope=payments:read customers:read offline_access
&code_challenge=…&code_challenge_method=S256&state=…
```
`skerry_partner` and `skerry_mode` may be added to say which Skerry account
and environment. Without them, a person who can act for more than one is
asked. The consent screen is shown every time, and what it lists is what
Skerry allows that person, which may be less than was asked. `offline_access`
gets a refresh token; each use replaces it, and using an old one again
revokes the grant.
**An agent, with no person present** (client credentials):
```
POST /token
Authorization: Basic
grant_type=client_credentials
&resource=https://api.skerry.xyz
&scope=payments:read
&skerry_agent=agt_…
```
Only a client in `AGENT_CLIENTS` may use this grant, and the token is issued
only if the agent's record at Skerry lists that client. There is no refresh
token; the client asks again when the token expires.
**DPoP** (RFC 9449). Send a `DPoP` proof header with the token request and
the token comes back with `token_type: DPoP` and `cnf.jkt`. A client marked
`dpop_required` is refused without one.
**Revocation and introspection.** `POST /token/revocation` with a refresh
token ends it and the grant it belongs to. A JWT access token cannot be
revoked or introspected here (the endpoint answers `unsupported_token_type`);
a resource server verifies it itself and it expires within fifteen minutes.
`POST /token/introspection` answers for opaque tokens.
**When no token is issued.** At the token endpoint: `invalid_grant` (or
`unauthorized_client` for the client credentials grant) when the resource
refuses, with the resource's reason in `error_description`; `invalid_scope`
when none of the scopes may be granted; HTTP 503 `temporarily_unavailable`
when the resource's hook could not be asked. A refresh token is not used up
by a refusal or a 503.
## The claims hook
A resource that wants its own facts in its access tokens registers a hook: an
entry in `HOOKED_RESOURCES` (`src/resources.js`) with the URL, the name of the
environment variable holding a shared secret, the request parameters it takes
and the claims it may set. Nothing else here is specific to a resource.
The hook is asked when the consent screen is drawn, when Allow is pressed, and
every time a token for the resource is issued (code exchange, refresh, client
credentials). Answers are not kept from one request to the next.
**Request**
```
POST
Content-Type: application/json
Praecise-Signature: t=1794571200000,v1=5f2c…
{
"account": {"id": "…", "email": "person@example.com", "email_verified": true},
"client_id": "https://host.example/oauth/client.json",
"scopes": ["payments:read", "customers:read"],
"resource": "https://api.skerry.xyz",
"requested": {"skerry_partner": "…", "skerry_mode": "sandbox"}
}
```
`account` is `null` for the client credentials grant. `scopes` is what the
token would carry if nothing is narrowed. `requested` holds the resource's own
parameters: those sent on the authorization request, what the person chose, or
for client credentials those sent on the token request.
`v1` is the hex HMAC-SHA256, keyed with the shared secret, of the `t` value, a
dot, and the raw body. `t` is the time in milliseconds. The resource refuses a
request whose `t` is more than five minutes from its own clock. The check is
`verifySignature` in `src/claims-hook.js`.
**Answers**
| answer | body | effect |
|---|---|---|
| claims | `{"claims": {…}, "scopes": […], "label": "…", "requested": {…}}` | the claims go in the token; the token carries the listed scopes that were asked for |
| choices | `{"choices": [{"label": "…", "detail": "…", "requested": {…}}]}` | on the consent screen the person picks one, and the hook is asked again with that `requested`; at the token endpoint this is a refusal |
| refusal | `{"error": "access_denied", "error_description": "…"}`, at any status | no token |
`label` is what the consent screen calls the account. `requested`, in a claims
answer, is the parameters that select the same answer again; it is kept with
the grant so that a refresh asks about the account that was consented to.
Both are optional.
The answer can only narrow: a scope that was not asked for is dropped. A claim
must be one the resource registered, and never `iss`, `sub`, `aud`, `exp`,
`iat`, `nbf`, `jti`, `client_id`, `scope` or `cnf`. Any other answer is a
failure, and a failure means no token: a status outside 2xx without an
`error`, a body that is not JSON or is larger than 16 kB, a redirect, or no
answer within the timeout (3 seconds unless configured).
## Asking for a passkey
A client that needs a passkey assertion, not just a session, adds to its
authorization request:
```
&acr_values=phr&max_age=300
```
The person is asked for a passkey unless the session was signed in with one
within `max_age` seconds. The ID token then carries `acr: "phr"`, `amr`
(`hwk` or `swk`, `user`, `mfa`; a sign-in that began with the password lists
`pwd` as well) and `auth_time`; the client checks all three. A `nonce` sent
with the request comes back unchanged, so the token can be tied to one action.
`auth_time` is included when `max_age` or `prompt=login` is sent. With
`prompt=none` and no such session the answer is `login_required`. Cancel on
the screen returns `access_denied`. Scope `openid` is required, as for any
request that uses these parameters.