Skip to content

Decide

POSThttps://api.ordainer.dev/v1/decide

Ask whether one tool call may run, before your handler does any work. We check the caller’s agent key against the plan it is bound to and answer allow or deny. Every answer writes one audit row.

  • A deny is an answer, not an error. allow and every deny return HTTP 200. Check decision, not the status code.
  • Every response carries an X-Request-Id header. On a decision it equals request_id in the body, which is also the audit row’s id.
  • Always send an idempotency key. A retry with the same key gets the original answer back and is not counted twice.
  • Errors always use one shape: {"error":{"code","message","request_id"}}.
  • Within /v1 we only add things. Ignore fields you don’t know. See Versioning.
  • Each agent key gets about 100 requests a second, each server counts separately, and 4 requests in flight at once on our hosted service, with up to 20 more queued briefly; over that you get a 429 with Retry-After.

Send the agent key as a Bearer token:

Authorization: Bearer ord_test_...
  • Keys look like ord_test_ or ord_live_ followed by 32 random characters.
  • Each key is bound to one customer and one plan. The key alone decides whose counter is used; there is no customer or plan parameter.
  • Keys are shown once when created. We store only a SHA-256 hash, so a lost key can’t be recovered: create a new one in Console → Keys.
  • Live keys are not available yet. Live keys open when paid plans launch. Your test keys cover everything in your beta.
Header Required Description
Authorization Yes Bearer <agent key>.
Content-Type Recommended application/json.
Idempotency-Key No (strongly recommended) 1 to 255 characters. Same meaning as the body field idempotency_key; used only when that field is absent or null.

A JSON object, up to 16,384 bytes. Unknown fields, such as units, quantity, cost or customer_id, are ignored. Every allowed call counts as 1. There is no field for counting a call as more than one.

toolstring, 1–200 charsRequired

The tool being called, for example research.run. Spaces around it are trimmed; after that it’s compared exactly and case-sensitively. A plan covers one tool, or every tool (*), never a list.

idempotency_keystring, 1–255 charsOptional

Makes retries safe. Scoped to the agent key. If present and not null, it is used and the Idempotency-Key header is ignored.

mode"enforce" | "shadow"Optional

Overrides the plan’s mode for this call. Leave it out to use the plan’s mode (Console → Plans). It never changes the decision; it only changes the mode echoed back.

Terminal window
curl -s -X POST https://api.ordainer.dev/v1/decide \
-H "Authorization: Bearer $ORDAINER_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: job-42-step-3" \
-d '{"tool":"research.run"}'

200 OK for allow and for every deny.

decision"allow" | "deny"

The answer.

reasonstring

ok on allow; quota_exceeded, tool_not_in_plan or test_limit_reached on deny. More may be added: treat an unknown reason on a deny as a deny.

remaininginteger ≥ 0 | null

Allows left on this key’s plan in the current period, after this decision. Never negative. null on a plan with no cap: check for null before doing arithmetic.

limitinteger ≥ 0 | null

The cap of the key’s plan when this decision was made: max allows per period for this key. null on a plan with no cap.

resets_atISO 8601 timestamp

When this answer can change: 00:00 UTC on the 1st of next month.

mode"enforce" | "shadow"

The effective mode: the request’s mode if you sent one, otherwise the plan’s (on test_limit_reached too).

request_idstring

req_ + 16 URL-safe characters. Same value as the X-Request-Id header and the audit row.

messagestring, optional

Only on an enforce test_limit_reached: a plain-language explanation. Don’t parse it.

reason decision Counted? What it means What to do
ok allow Yes Under the cap. Run the tool.
quota_exceeded deny No. Denies are always free, on test keys and live keys. This key’s cap for the period is used up. Don’t run the tool (in enforce). Retrying before resets_at gets the same answer.
tool_not_in_plan deny No. Denies are always free, on test keys and live keys. The key’s plan is bound to a different tool. Don’t run the tool. Waiting won’t help: change the plan’s tool or move the key to a plan that covers it.
test_limit_reached deny No. In shadow it counts only toward the safety ceiling. Test keys only. Your organization used its free test-key decisions for this UTC month. Enforce: don’t run the tool. Shadow: run it, like any would-deny, up to the safety ceiling. It’s our sandbox limit, not your customer’s plan. Live keys are not affected.

mode doesn’t change the decision. It tells you what to do with a deny: in enforce, don’t run the tool; in shadow, run it anyway, and the deny is recorded as “would deny”.

The whole rule: run the tool if decision is allow or mode is shadow.
HTTP/1.1 200 OK
X-Request-Id: req_bUyY8o6enyPzZvEL
Content-Type: application/json; charset=utf-8
Cache-Control: no-store
{"decision":"allow","reason":"ok","remaining":4,"limit":5,"resets_at":"2026-11-01T00:00:00.000Z","mode":"enforce","request_id":"req_bUyY8o6enyPzZvEL"}

The tool_not_in_plan example is a key whose plan is bound to web.search (cap 100, 2 used) asking for research.run. Denies never count toward the plan limit, so remaining is unchanged.

  • Scope: per agent key. The same string on two different keys means two different requests.
  • How long a key replays: 24 hours from the first call with it. A retry gets the stored answer (same body, same request_id) and Idempotent-Replayed: true. No counter moves and no new audit row is written.
  • Same key, different tool or mode: 409 idempotency_conflict. Use a new key for a different request.
  • Plan switched between shadow and enforce: a retry with the same key still gets the original answer, decided under the mode the plan had at the time. Use a new key to get a decision under the new mode.
  • No key: every request is a new decision and is counted. Only retry 500 internal_error if you sent a key.

Errors are non-200 responses with this body. Requests that end in an error write no audit row and never move a counter. Branch on error.code; the message wording may change.

{
"error": {
"code": "missing_key",
"message": "Send your agent key as \"Authorization: Bearer ord_...\".",
"request_id": "req_EWJ4qahPhrssV5a5"
}
}
HTTP code When Retry?
400 invalid_request The body isn’t a JSON object, or tool or mode is missing or invalid. message names the field. After fixing
400 invalid_idempotency_key The idempotency key isn’t a string of 1–255 characters. After fixing
401 missing_key No Authorization: Bearer … header. After fixing
401 unknown_key The key isn’t recognized. No
401 revoked_key The key was revoked in the console. No
403 subscription_required A live key of an organization with no paid subscription. Test keys still work. No, subscribe first
403 subscription_inactive A live key whose organization’s subscription has ended. Test keys still work. No: pay the open invoice by day 30 after the missed renewal (same keys back), else resubscribe
409 idempotency_conflict This idempotency key was already used, on this agent key, with a different tool or mode. No, use a new key
429 rate_limited About 100 requests a second, each server counts separately. limit_scope is per_key. Yes, after Retry-After, same idempotency key
429 concurrency_limited 4 requests already in flight on this key on our hosted service, and 20 more queued. limit_scope is per_key_concurrency. Yes, after Retry-After, fewer calls at once
429 safety_limit_reached A shadow call on a test key past 10 times your organization’s monthly test limit (Shadow mode at the test limit). limit_scope is org_safety. Not a deny: let the tool run (SDK 1.2.0 does). After resets_at (the start of next month, UTC)
500 internal_error A bug or database failure on our side. Yes, same idempotency key
503 decide_timeout The decision didn’t finish within the server’s deadline (250 ms by default); rolled back, nothing counted. Yes, after Retry-After: 1, same idempotency key
503 db_unavailable Our database couldn’t be reached or cancelled this decision; no decision returned. Yes, after Retry-After: 1, same idempotency key
503 db_timeout A database query on the decision path ran too long and was cancelled; nothing counted. Yes, after Retry-After: 1, same idempotency key

Every code, including invalid_json, old_key_format, too_many_failed_auth, payload_too_large and db_timeout, has its own entry with a fix on Errors.

  • Counters are per agent key, not per customer or plan. Two keys on the same plan each get the full cap.
  • Every allowed call counts as 1. Extra fields such as units, quantity, cost or customer_id are ignored. Denies count 0.
  • Periods are UTC calendar months. resets_at is 00:00 UTC on the 1st, which is the evening before in North America (8 PM ET in summer, 7 PM ET in winter).
  • Caps can be 0: a plan with a cap of 0 denies every call with quota_exceeded.
  • Plans and keys can change mid-period. See Plan changes during a period.
  • Under parallel load the cap is exact: concurrent calls can never push a key past its cap. The same holds for the test-key limit.
  • At most 4 requests in flight at once per agent key on our hosted service (per server, each server counts separately). Up to 20 more are queued, waiting briefly for a free slot, so small bursts just run one after another; the rest get 429 concurrency_limited ("limit_scope": "per_key_concurrency", Retry-After: 1); no decision, nothing counted, no audit row. The cap is a server setting and can’t be changed per account. Agents making parallel tool calls can hit it: cap how many tool calls run at once per agent key, or retry refused ones after 1 second with the same idempotency key.
  • Errors aren’t audited: 4xx/5xx requests write no audit row. Every 200 (allow or deny, test_limit_reached included) writes exactly one. Our server log keeps one line per 4xx (request ID, status, error code and organization, plus limit_scope on a 429; never your key, address, body, headers or query), so support can trace an errored request from its X-Request-Id, and every 4xx and 5xx answer (plus idempotent replays) is counted per organization and day.
  • Server-to-server only. There is no CORS, on purpose: the API sends no CORS headers and answers OPTIONS with 405. Keep agent keys on your server, never in a web page.
  • Find a decision by its request_id: Console → Decisions → paste it into Request ID. Exact match, your organization only, anywhere in your retention window. A request that got an error (any 4xx/5xx) wrote no row, so its ID is never found; the not-found message says so. Send that ID to support instead. A test_limit_reached row shows 0/1,000 (your monthly test limit, not the plan allowance), with your organization’s own limit in place of 1,000 if we’ve set one. Results come with a one-row CSV download. Export the log as CSV or JSON from the same page (console login; there is no API-key export endpoint in V0).
  • Rate limit. About 100 requests a second, each server counts separately. Over it: 429 rate_limited with Retry-After (whole seconds, at least 1); no decision, nothing counted, no audit row. The limit is counted on each of our servers separately, so the exact point where it starts is approximate: don’t design around a precise threshold, and treat any 429 as “wait Retry-After, then retry with the same idempotency key”. Well-formed unknown keys are limited too. The test-key limit above is a monthly quota, not a rate limit.
  • Limit on failed keys: 30 a minute per address. Requests with a missing, wrongly shaped or unknown key use up a per-address allowance of 30 (IPv4: per address; IPv6: per /64 block), refilling one every 2 seconds. When it’s empty, further requests with a missing, malformed or unknown key get 429 too_many_failed_auth ("limit_scope": "per_address") with Retry-After instead of their 401. A valid key is not refused by this limit, so services sharing an outgoing address (NAT gateway, proxy, cloud egress IP) aren’t blocked by one service’s bad key. The one exception: if the address is over budget and the server can’t check a key it doesn’t already know in time (overloaded, or the check timed out), that request gets a 429 whose message says the key couldn’t be checked and to retry after Retry-After; these are recorded by request ID. Successful calls, revoked_key and old sk_ keys (401 old_key_format) never use it up. Fix the key; waiting alone won’t help. Counted on each server separately, like the per-key limit.
  • Latency and status: we don’t publish a latency number yet, and there is no status page yet. Set your own client timeout (the Node SDK defaults to 300 ms).
  • Cold starts and the first call. The first call from a new process, a cold JVM or a fresh serverless instance also has to open the connection, so it can take longer than the calls after it. Two things keep it inside your timeout: make one warm-up request at startup (GET /livez on the API address; it needs no key, moves no usage counter and writes no audit row), and on a timeout retry once with the same idempotency key, so the retry is answered once and counted once. Our Node, Python and Ruby SDKs retry this way by default: 300 ms per attempt and one retry, 650 ms in all. They don’t send a warm-up request; add one yourself if your first call matters.

In the console you can edit a plan’s allowance and tool (Console → Plans → Save on the plan’s row), switch its mode, and move a key to another plan (Console → Keys → move to plan). None of this changes the request or the response format. What it changes is the values:

  • From the next decision. A change applies to the next decision of every key on that plan. No deploy, and the key’s secret doesn’t change.
  • The count carries over. The current period’s count belongs to the key, so it stays the same after a cap change or a move. Lower the cap below what a key has used and its next calls get deny / quota_exceeded with remaining: 0 and the new limit. Raise it, and they’re allowed again at once.
  • A tool change takes effect the same way: calls for a tool the plan no longer covers get deny / tool_not_in_plan. A plan covers one tool or every tool (*), never a list. The console trims spaces from the tool name, and an empty tool means *.
  • No decision mixes old and new values. A change waits for decisions already in progress on that plan or key; decisions that start meanwhile wait for the change and then use the new values.
  • Retries: a retry with the same idempotency key gets the stored answer, with the values that applied when it was first decided.
  • The audit row of each decision records the cap that applied (limit) and the plan the key was on at the time.

Example: a key on a plan with cap 5 had used 3 allows; the cap was then lowered to 2.

{
"decision": "deny",
"reason": "quota_exceeded",
"remaining": 0,
"limit": 2,
"resets_at": "2026-11-01T00:00:00.000Z",
"mode": "enforce",
"request_id": "req_Po6gTTFD76C9umPJ"
}

After raising the cap to 6, the same key’s next call:

{
"decision": "allow",
"reason": "ok",
"remaining": 2,
"limit": 6,
"resets_at": "2026-11-01T00:00:00.000Z",
"mode": "enforce",
"request_id": "req_0RVeCRAxh7GlhgUh"
}

Moves only go to a plan shared by all your customers or to a plan of the key’s own customer, with the same period. Console → Change log records plan and key changes made in the console: cap and tool edits, mode switches, key moves, and key creation and revocation, plus changes we make to your organization’s monthly test-key limit (shown as made by “Ordainer support”, with the reason). Each line shows who, when, and what changed (for example max_allows: 5 → 2). A key move also shows “used this period: N (carried over)”; key creation and revocation show the key’s prefix (never the secret), customer and plan. The sandbox keys made at sign-up aren’t console actions, so they aren’t listed. The change log can be viewed in the console only; there is no export for it in V0.

See Errors → Console errors for what the console says when a change is refused.