Keys for AI agents
An agent key is a scoped secret key with hard limits: per-currency spending caps, a corridor allowlist, a verified-recipient rule, mandatory expiry and, in production, no real money until the account owner enables it with a step-up verification. Everything else about the API is identical.
What an agent key can do
Create one under Developers > API keys > Create key > Agent key. It needs a label, a purpose (shown on every audit line it produces), an expiry of at most 90 days, the countries and methods it may use, at least one currency cap, and a beneficiary policy.
The key authenticates exactly like a server key (Authorization: Bearer sk_test_... or sk_live_...). GET /v1/whoami tells the agent what it may do and how much budget is left, so an agent never has to guess.
Allowed scopes: collections:read, collections:write, payouts:read, payouts:write, refunds:read, refunds:write, balances:read, events:read.
Refused at creation: events:write, webhooks:read, webhooks:write and the * wildcard. An agent never re-points webhooks or replays events. Agents cannot request FX quotes either.
Caps and windows
Caps are per currency, in that currency, in three sizes: per transaction, per UTC calendar day and per UTC calendar month. A money POST (collections, payouts, refunds) in a currency with no cap row is refused: fail closed.
Order of checks: the currency cap row must exist, then the corridor, then per transaction, then the day and month windows. The windows are reserved BEFORE the intent row is written, atomically, so two concurrent requests can never overshoot a cap together.
The budget counts attempts. A request that the platform refuses before any row exists (a corridor outside the allowlist, a failed risk check, an insufficient balance) gives its reservation back; a payout the provider later rejects, a payer timeout or a cancellation does not. Size the daily cap with retries in mind.
Replays: a cap refusal answered inside the handler is stored with the Idempotency-Key like any other response. Replaying the same key returns the same 403 even after the window reset. Use a new Idempotency-Key for a new attempt. Only the live-money refusal is answered before idempotency and is never stored.
HTTP/1.1 403 Forbidden
{
"status": "error",
"error": {
"type": "invalid_request_error",
"code": "agent_cap_daily_exceeded",
"reason_code": "agent_cap_daily_exceeded",
"message": "This agent key has reached its daily spending cap for this currency.",
"next_step": "Wait for the daily window to reset (key.caps[].day_resets_at on GET /v1/whoami), or ask the account owner to raise the cap under Developers > API keys.",
"request_id": "c2d3e4f5-6a71-4b82-9c03-d1e2f3a4b5c6"
}
}Corridors and recipients
allowed_countries and allowed_methods are validated when the key is created against the corridors the gateway actually serves in that environment, for the directions the scopes imply (payouts:write and refunds:write need a payout leg that is routable today; collections:write needs a deposit leg; the same catalog the dashboard payout form reads). Every country and method combination you list must be served: a country served for mobile money does not make its bank leg available, and the refusal names the method (AGENT_METHOD_NOT_SERVED) when the country itself is served. A request outside the allowlist answers agent_corridor_not_allowed before any routing; POST /v1/resolve obeys the same allowlist.
With beneficiary_policy resolved_only (the default), POST /v1/payouts succeeds only for a recipient this merchant resolved beforehand: a POST /v1/resolve that answered found: true and whose result has not expired. The rule keys on the destination (msisdn, or bank code + account number), never on a name the caller typed.
Live money in production
A production agent key starts with live money OFF. It authenticates, reads balances, lists payouts and events, but every money POST answers 403 agent_live_money_disabled until the account OWNER enables live money for that key in the dashboard. Enabling requires a signed-in session that passed a second factor (an authenticator app); there is no way around it through the API or a support ticket without a written reason in the admin audit trail.
Sandbox agent keys never touch a real provider and are never blocked by this rule.
The rule is enforced in the database, not only in the API: consume_api_key_spend refuses before any row exists, and a guard on the payment tables refuses the row itself, so even a bug in the API layer cannot move money for a key whose live money is off.
Once live money is on, widening the key (raising a cap, adding a currency, removing or raising the approval line) and resuming it after a pause need the owner's step-up session again. Lowering a cap, pausing and switching live money off stay one click: stopping never waits for an authenticator.
HTTP/1.1 403 Forbidden
{
"status": "error",
"error": {
"type": "invalid_request_error",
"code": "agent_live_money_disabled",
"reason_code": "agent_live_money_disabled",
"message": "This production agent key is not yet allowed to move real money.",
"next_step": "The account owner must enable live money for this key under Developers > API keys (a step-up verification is required). Reads keep working; sandbox keys are never blocked.",
"request_id": "d3e4f5a6-7b82-4c93-8d04-e1f2a3b4c5d6"
}
}Approvals
A cap may carry approval_above. A payout at or above it is created but held: approval_status PENDING_APPROVAL with hold_reasons ["AMOUNT_THRESHOLD"], exactly like the merchant-wide threshold. A human owner or admin approves it in the dashboard; the key itself can never approve.
{
"status": "success",
"data": {
"id": "po_01J9…",
"amount": "4040",
"currency": "XAF",
"status": "pending",
"approval_status": "PENDING_APPROVAL",
"hold_reasons": ["AMOUNT_THRESHOLD"],
"livemode": false
},
"request_id": "e4f5a6b7-8c93-4da4-9e15-f2a3b4c5d6e7"
}Read your limits
GET /v1/whoami returns the key kind, its label and purpose, expiry, the paused flag, live_money_enabled, the allowlists, the beneficiary policy and one entry per capped currency with what was used today and this month, what remains, and when each window resets. Amounts are decimal strings in the currency.
{
"status": "success",
"data": {
"merchant": { "id": "…", "public_id": "mch_01HZ…", "trading_name": "Sunrise Electronics" },
"app": { "id": "…", "name": "Ops agent app", "environment": "SANDBOX" },
"key": {
"prefix": "sk_test_8fK2",
"scopes": ["payouts:read", "payouts:write", "balances:read"],
"kind": "agent",
"label": "Supplier payouts agent",
"purpose": "Pays approved supplier invoices under 5,000 XAF",
"expires_at": "2026-10-04T09:12:00.000Z",
"paused": false,
"live_money_enabled": false,
"allowed_countries": ["CM"],
"allowed_methods": ["mobile_money"],
"beneficiary_policy": "resolved_only",
"caps": [
{
"currency": "XAF",
"per_transaction": "5000",
"per_day": "12000",
"per_month": "20000",
"approval_above": "3000",
"used_today": "4040",
"used_this_month": "4040",
"remaining_today": "7960",
"remaining_this_month": "15960",
"day_resets_at": "2026-09-06T00:00:00.000Z",
"month_resets_at": "2026-10-01T00:00:00.000Z"
}
]
},
"livemode": false
},
"request_id": "b1e2c3d4-5f60-4a71-8b92-c0d1e2f3a4b5"
}Refusal codes
All nine are HTTP 403 with type invalid_request_error; error.reason_code repeats the code. They are seeded in the closed reason-code vocabulary before any code emits them.
| Code | When | What to do |
|---|---|---|
agent_live_money_disabled | A money POST from a production agent key whose owner has not enabled live money yet. | Ask the account owner to enable live money for the key. Reads keep working. |
agent_currency_not_allowed | The request currency has no cap row on the key. | Use a currency listed in key.caps, or ask for a cap in this currency. |
agent_corridor_not_allowed | The country or the method is outside the key allowlist. | Use a country and method from key.allowed_countries and key.allowed_methods. |
agent_beneficiary_not_allowed | beneficiary_policy is resolved_only and the recipient was not resolved as found, or the resolution expired. | Call POST /v1/resolve first; retry with a new Idempotency-Key once it answers found: true. |
agent_cap_per_transaction_exceeded | The amount is above per_transaction. | Reduce the amount to per_transaction or below. |
agent_cap_daily_exceeded | The amount would take the UTC day past per_day. | Wait for day_resets_at or ask for a higher daily cap. |
agent_cap_monthly_exceeded | The amount would take the UTC month past per_month. | Wait for month_resets_at or ask for a higher monthly cap. |
agent_batch_not_allowed | POST /v1/payouts/batch with an agent key, or with a server key that carries caps or an allowlist. | Submit payouts one at a time with POST /v1/payouts, or batch with an unrestricted server key. |
agent_key_rotated | A money POST signed with a key that was rotated (its replacement carries the caps and the budget). | Switch to the replacement key returned by the rotation. Reads keep working on the old key until it expires. |
Batches
POST /v1/payouts/batch is not available to agent keys, nor to server keys that carry caps or an allowlist, and answers 403 agent_batch_not_allowed before idempotency: one batch would bypass the per-item caps, corridors and the recipient rule. Submit payouts one at a time, or batch with an unrestricted server key.
Pause and revoke
Any owner, admin or developer can pause an agent key in one click with a reason; every request signed with it then fails with the ordinary 401 invalid_api_key (a paused key is indistinguishable from a bad one, on purpose) until an owner or admin resumes it. Revoking stays permanent. Rotating an agent key keeps its caps, its policy and the budget already used today and this month on the replacement; the replacement starts with live money OFF (the owner enables it again with a step-up), a paused key rotates into a paused key, and the old key can still read until it expires but every money POST on it answers 403 agent_key_rotated: one budget, never two.
Audit
Every request logs the key kind; every money POST logs a cap decision (allowed with the remaining amounts, or denied with the code). The dashboard API logs show both, and the agent key drawer lists the recent refusals per key. Creating, pausing, resuming, editing caps and flipping live money each write an audit row.