Quickstart
From a new account to your first decision, then what to do with each answer.
What Arbiter does
A login isn’t permission. Arbiter checks every action before it happens. Your backend describes the action an agent wants to take. Arbiter checks it against your policy and the grant the agent holds, and answers with a verdict. Your backend acts only when the verdict allows it, then reports how it went.
Choose your integration
Use the HTTP API directly, the server SDK, or a local MCP adapter.
Create your workspace
Sign up and name your workspace. It is a sandbox, and it comes with a starter, so your first request needs no setup:
- An action,
refund.createversion 1: a refund of an order, with an amount in US dollars and where the money goes. - A policy. Refunds up to $100 are allowed. Above that, a person in your workspace has to approve each one.
- A grant from you to your workspace’s agent. The agent may ask for refunds up to $1,000 each, for 30 days. Anything above $1,000 is denied. The refunds it commits add up to at most $2,000 in any 24 hours; one that would go past that is denied too.
Create two keys
Open API keys in Settings and create both:
- A management key acts for you. Your backend sends it with every request on this page. Keep it on your server and never give it to the agent. Creating one asks you to confirm it’s you.
- An agent key identifies your agent. Your backend names its id when it opens a trust session. The agent uses its secret only to check on a request that waits for approval.
Each secret is shown once, when you create the key. Copy it then. You can revoke a key on the same page at any time, and requests using it are refused from then on. A workspace can have 10 active keys of each kind.
Set these in your shell. The ids are on API keys, under Workspace IDs.
export ARBITER_MANAGEMENT_KEY="paste the management key’s secret"
export ARBITER_AGENT_KEY_ID="paste the agent key’s id"
export ARBITER_AGENT_KEY="paste the agent key’s secret"
export ARBITER_PRINCIPAL_ID="paste the Principal under Workspace IDs"
export ARBITER_ACTOR_ID="paste the Actor under Workspace IDs"Make your first decision
The requests below need curl and uuidgen in a POSIX shell.
First, open a trust session. Your backend says that the agent’s user signed in, and names the agent key the session is for. The session lasts 15 minutes from observedAt.
curl -s "https://api.arbiterid.com/v1/sessions" \
-H "Authorization: Bearer $ARBITER_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data-binary @- <<JSON
{
"requesterCredentialId": "$ARBITER_AGENT_KEY_ID",
"evidenceRef": "first_sign_in",
"observedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"durationMinutes": 15
}
JSON{
"session": {
"id": "tsession_e5de27ed-c1ad-4944-b929-7d3325dc14be",
"organizationId": "org_4bf9a09445549ae482852b275a97defd",
"environmentId": "sandbox",
"principal": "principal_538468f1a3d6be90fa9aaab8b7bcc3b6",
"actor": "actor_b35318d62dbcefd39772ad871dcf4cec",
"requesterCredentialId": "credential_1f519916-4a46-453f-8c18-ac86a8e44017",
"revision": 1,
"status": "active",
"createdAt": "2026-09-30T18:13:30.092Z",
"expiresAt": "2026-09-30T18:27:30.087Z",
"attestation": {
"source": "customer_backend",
"issuerCredentialId": "credential_b28119d2-5784-4fae-ab73-29517b32a1a7",
"operatorId": "user_example0000000000000000000",
"evidenceRef": "first_sign_in",
"observedAt": "2026-09-30T18:12:30.087Z",
"recordedAt": "2026-09-30T18:13:30.092Z"
}
}
}Set ARBITER_SESSION_ID to session.id from the answer.
Then ask about a refund. Each tab sends the starter action with a different amount.
A refund of $25 answers ALLOW.
curl -s "https://api.arbiterid.com/v1/actions/evaluate" \
-H "Authorization: Bearer $ARBITER_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data-binary @- <<JSON
{
"actionId": "refund.create",
"schemaVersion": 1,
"sessionId": "$ARBITER_SESSION_ID",
"resource": {
"order_id": "order_1001"
},
"parameters": {
"amount": {
"amount_minor": 2500,
"currency": "USD"
},
"destination_id": "pm_original_card"
}
}
JSON{
"decision": {
"id": "dec_76ea4cedf88f47cfad666c26132b8a82",
"organizationId": "org_4bf9a09445549ae482852b275a97defd",
"environmentId": "sandbox",
"actionId": "refund.create",
"schemaVersion": 1,
"action": {
"resource": {
"order_id": "order_1001"
},
"resourceVersion": null,
"parameters": {
"amount": {
"amount_minor": 2500,
"currency": "USD"
},
"destination_id": "pm_original_card"
}
},
"actorId": "actor_b35318d62dbcefd39772ad871dcf4cec",
"principalId": "principal_538468f1a3d6be90fa9aaab8b7bcc3b6",
"sessionId": "tsession_e5de27ed-c1ad-4944-b929-7d3325dc14be",
"session": {
"kind": "trust_session"
},
"actionDigest": "sha256:fd5faccd62036282b86835a224d84da67ff1b1ec652af850ac90d6a4da2c2069",
"reasonCodes": [],
"grantIds": [
"grant_bd17de98a90b417ea56d7e6e4a46c148"
],
"policyVersion": 1,
"risk": {
"status": "unavailable"
},
"createdAt": "2026-09-30T18:13:30.099Z",
"expiresAt": "2026-09-30T18:18:30.099Z",
"requiredEvidence": [],
"approvalId": null,
"actorResponse": {
"verdict": "ALLOW",
"reason": null,
"remediation": []
},
"verdict": "ALLOW"
}
}Set ARBITER_DECISION_ID to decision.id from the answer.
The same two calls in TypeScript, as the dashboard’s Connect your agent panel shows them. It prints the verdict.
const api = "https://api.arbiterid.com"
async function post(path: string, body: object) {
const response = await fetch(api + path, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ARBITER_MANAGEMENT_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify(body),
})
return response.json()
}
// Your backend saw the agent's user sign in a minute ago.
const { session } = await post("/v1/sessions", {
requesterCredentialId: process.env.ARBITER_AGENT_KEY_ID,
evidenceRef: "first_sign_in",
observedAt: new Date(Date.now() - 60_000).toISOString(),
durationMinutes: 15,
})
const { decision } = await post("/v1/actions/evaluate", {
"actionId": "refund.create",
"schemaVersion": 1,
"sessionId": session.id,
"resource": {
"order_id": "order_1001"
},
"parameters": {
"amount": {
"amount_minor": 2500,
"currency": "USD"
},
"destination_id": "pm_original_card"
}
})
console.log(decision.actorResponse.verdict)Server SDK
The typed server client covers evaluation, approval status, redemption, and outcome reporting. It requires Node.js 24 or later and keeps credentials on your trusted backend. Give the agent only itsdecision.actorResponse.
Registry publication is pending. Until the SDK is published, use the HTTP examples on this page to get started.
Read the answer
Your agent’s answer is decision.actorResponse. It says only what the agent may know. The rest of the decision, such as reasonCodes, is for your backend and your logs.
ALLOW- Go ahead. Your backend redeems the decision, performs the action and reports the outcome.
ALLOW_CONSTRAINED- Accepted, but the effect is scheduled.
constraintsgives the earliest start and when the cancel window closes.cancelsays the principal can stop it from a notice until then. The agent waits and cannot cancel it. The starter never answers this. STEP_UP- A person has to approve this exact action. Tell your user it is waiting, and do not retry with a changed action to get around it.
HOLD- Held for review by your team. Wait: a
HOLDis pending, not refused. The starter never answers this. DENY- Do not perform the action.
reasonandremediationsay whether anything the agent could change would help.
reason names why, and remediation lists what would help, by kind:
AWAIT_APPROVAL- Someone must approve.
approverRolesays who:workspace_operator(your dashboard operators) oraccount_owner(the end user’s passkey). AWAIT_PENDING_APPROVAL- The action is waiting for approval. Check its state with
pendingActionIdandapprovalHandle. The handle only reads or withdraws; it never approves. FIT_ENVELOPE- The grant caps this field.
boundgives the most the agent may ask for. A smaller request may pass. REQUEST_GRANT_CHANGE- Only a new or wider grant from the principal helps. Ask a person; the agent cannot fix this itself.
RETRY_AFTER- Too many requests. Only in a 429 answer, from any route, with a matching
Retry-Afterheader. Nothing was evaluated, redeemed or stored, so waitretryAfterSecondsand send the same request again with the same key. NONE- Nothing the agent can do changes this answer.
Ignore fields you don’t know. New meaning arrives as a new verdict or a new kind, never as a changed field. Treat a verdict you don’t recognize as not allowed.
When a person has to approve
The $250 refund answered STEP_UP with an AWAIT_PENDING_APPROVAL entry. Your request now waits for approval.
- Someone in your workspace approves it in the dashboard, under Decisions, in Approvals waiting on you. Approving asks you to confirm it’s you, and your account needs two-step verification.
- Meanwhile the agent can check the request with its own key. Its
stateturns fromWAITING_FOR_APPROVALtoAPPROVED. - Send the same evaluate request again. It now answers
ALLOW, anddecision.approvalIdnames the approval. Redeem it beforeexpiresAtin the status answer.
curl -s "https://api.arbiterid.com/v1/pending-actions/$ARBITER_PENDING_ACTION_ID" \
-H "Authorization: Bearer $ARBITER_AGENT_KEY" \
-H "Arbiter-Approval-Handle: $ARBITER_APPROVAL_HANDLE"{
"pendingAction": {
"id": "pa_797e93233b3b4f5199951f9464c81354",
"state": "WAITING_FOR_APPROVAL",
"actionId": "refund.create",
"createdAt": "2026-09-30T18:13:30.113Z",
"updatedAt": "2026-09-30T18:13:30.113Z",
"expiresAt": "2026-09-30T18:18:30.108Z",
"approverRole": "workspace_operator"
}
}If nobody approves before expiresAt, the request expires. Evaluate again to ask again.
Redeem, then report the outcome
An ALLOW is not the refund. Right before your backend performs the action, it redeems the decision: the same action and session, plus the decision’s id. A decision can be redeemed once, within 5 minutes. If the action differs in any way, redeem refuses it.
curl -s "https://api.arbiterid.com/v1/commit-authorizations/redeem" \
-H "Authorization: Bearer $ARBITER_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data-binary @- <<JSON
{
"actionId": "refund.create",
"schemaVersion": 1,
"sessionId": "$ARBITER_SESSION_ID",
"resource": {
"order_id": "order_1001"
},
"parameters": {
"amount": {
"amount_minor": 2500,
"currency": "USD"
},
"destination_id": "pm_original_card"
},
"decisionId": "$ARBITER_DECISION_ID"
}
JSON{
"redemption": {
"id": "rdm_2072d72cc83e4e609effca432cc5b40b",
"decisionId": "dec_76ea4cedf88f47cfad666c26132b8a82",
"actionDigest": "sha256:fd5faccd62036282b86835a224d84da67ff1b1ec652af850ac90d6a4da2c2069",
"constraintDigest": null,
"redeemedAt": "2026-09-30T18:13:30.127Z"
}
}Set ARBITER_REDEMPTION_ID to redemption.id from the answer.
Perform the refund only after redeem succeeds. Then report it:
succeeded: the refund happened.failed_no_effect: it failed and changed nothing.unknown: you cannot tell yet; report the final status later.
curl -s "https://api.arbiterid.com/v1/outcomes" \
-H "Authorization: Bearer $ARBITER_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data-binary @- <<JSON
{
"redemptionId": "$ARBITER_REDEMPTION_ID",
"status": "succeeded"
}
JSON{
"outcome": {
"id": "out_225997658c4848109d7e998d6bf82b9a",
"sequence": 1,
"state": "succeeded",
"externalReference": null,
"reportedBy": "credential_b28119d2-5784-4fae-ab73-29517b32a1a7",
"reportedAt": "2026-09-30T18:13:30.134Z",
"organizationId": "org_4bf9a09445549ae482852b275a97defd",
"environmentId": "sandbox",
"redemptionId": "rdm_2072d72cc83e4e609effca432cc5b40b",
"decisionId": "dec_76ea4cedf88f47cfad666c26132b8a82",
"previousState": "awaiting_outcome"
},
"receipt": {
"encoding": "arbiter-receipt-v1",
"decisionId": "dec_76ea4cedf88f47cfad666c26132b8a82",
"organizationId": "org_4bf9a09445549ae482852b275a97defd",
"environmentId": "sandbox",
"actionId": "refund.create",
"schemaVersion": 1,
"actionDigest": "sha256:fd5faccd62036282b86835a224d84da67ff1b1ec652af850ac90d6a4da2c2069",
"actorId": "actor_b35318d62dbcefd39772ad871dcf4cec",
"principalId": "principal_538468f1a3d6be90fa9aaab8b7bcc3b6",
"sessionId": "tsession_e5de27ed-c1ad-4944-b929-7d3325dc14be",
"session": {
"kind": "trust_session"
},
"verdict": "ALLOW",
"reasonCodes": [],
"requiredEvidence": [],
"grantIds": [
"grant_bd17de98a90b417ea56d7e6e4a46c148"
],
"policyVersion": 1,
"risk": {
"status": "unavailable"
},
"decidedAt": "2026-09-30T18:13:30.099Z",
"decisionExpiresAt": "2026-09-30T18:18:30.099Z",
"approval": null,
"redemption": {
"id": "rdm_2072d72cc83e4e609effca432cc5b40b",
"redeemedAt": "2026-09-30T18:13:30.127Z",
"constraintDigest": null
},
"outcomes": [
{
"id": "out_225997658c4848109d7e998d6bf82b9a",
"sequence": 1,
"state": "succeeded",
"externalReference": null,
"reportedBy": "credential_b28119d2-5784-4fae-ab73-29517b32a1a7",
"reportedAt": "2026-09-30T18:13:30.134Z"
}
],
"state": "succeeded"
},
"receiptHash": "sha256:973938d2f0528876cf68f81e9a2c4148914fde2077d5a0202a9cc22f5a548a98"
}The answer carries the receipt: the decision, the redemption and every outcome, with a hash over them.
Run Arbiter beside your checks
Observe mode changes nothing in your systems. You send events for actions that already happened, and Arbiter records what it would have decided. Every answer says it is hypothetical, and nothing in it can be redeemed.
curl -s "https://api.arbiterid.com/v1/observations" \
-H "Authorization: Bearer $ARBITER_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data-binary @- <<JSON
{
"actionId": "refund.create",
"schemaVersion": 1,
"principalId": "$ARBITER_PRINCIPAL_ID",
"actor": {
"kind": "registered",
"actorId": "$ARBITER_ACTOR_ID"
},
"occurredAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"source": "webhook",
"resource": {
"order_id": "order_1002"
},
"parameters": {
"amount": {
"amount_minor": 25000,
"currency": "USD"
},
"destination_id": "pm_original_card"
}
}
JSON{
"observation": {
"mode": "observe",
"hypothetical": true,
"enforced": false,
"id": "obs_4b9913f9416a415d81977885d3621add",
"actionId": "refund.create",
"schemaVersion": 1,
"principalId": "principal_538468f1a3d6be90fa9aaab8b7bcc3b6",
"actor": {
"kind": "registered",
"actorId": "actor_b35318d62dbcefd39772ad871dcf4cec"
},
"source": "webhook",
"occurredAt": "2026-09-30T18:18:44.000Z",
"recordedAt": "2026-09-30T18:18:44.324Z",
"resource": {
"order_id": "order_1002"
},
"resourceVersion": null,
"actionDigest": "sha256:482531a0f53bb69a1481826a4e4a09bf3911c1bccc378fcd16163ca1b4e07ee1",
"decision": {
"verdict": "STEP_UP",
"reasonCodes": [
"POLICY_LARGE_REFUND"
],
"policyVersion": 1,
"grantIds": [
"grant_bd17de98a90b417ea56d7e6e4a46c148"
],
"evaluatedAsOf": "2026-09-30T18:18:44.000Z",
"evidence": {
"session": "not_observed",
"risk": "unavailable",
"approvals": "not_consulted",
"noticeChannels": "not_consulted"
}
}
}
}Arbiter checks the event against your policy and grants as they stood at occurredAt. It can’t see sessions or approvals. An event from before your workspace existed finds no grant, so it reads DENY.
curl -s "https://api.arbiterid.com/v1/observations/summary" \
-H "Authorization: Bearer $ARBITER_MANAGEMENT_KEY"{
"mode": "observe",
"hypothetical": true,
"enforced": false,
"asOf": "2026-09-30T18:18:44.341Z",
"window": {
"days": 30,
"from": "2026-09-01T00:00:00.000Z",
"to": "2026-10-01T00:00:00.000Z"
},
"total": 1,
"counts": [
{
"actionId": "refund.create",
"verdict": "STEP_UP",
"count": 1
}
]
}Observe in Settings shows the same counts.
Grants end
The starter grant lasts 30 days, the longest a grant can. It never renews itself. Once it ends, every evaluate answers:
decision.actorResponse after the grant ends{
"verdict": "DENY",
"reason": "GRANT_ENDED",
"remediation": [
{
"kind": "REQUEST_GRANT_CHANGE"
}
]
}Renew it in Agent access. Agent access flags a grant 7 days before it ends, and you can renew it until 30 days after. Renewing gives the agent a new grant with the same terms for another 30 days.
Sandbox limits
Every workspace starts as a sandbox. It can’t move real money or change real systems, and the starter’s refunds are examples. Arbiter decides; it never performs the refund. The sandbox is free while it is offered.
Read the Terms of Service, where section 5 covers the sandbox, and the Privacy Policy.
Going live
To use Arbiter on real actions, email tyler@grayhavenindustries.com for a signed agreement or written approval. We then add a live environment beside your sandbox, which stays as it is. Switch to it in the dashboard and create new keys there: sandbox keys never work in live. Live starts empty, with no starter, and allows five times the sandbox’s request rate. Its policies for money movement, credential and administrative actions must answer STEP_UP, HOLD or DENY when no risk result is available. Section 6 of the Terms of Service covers live use.
