API Documentation
Everything you need to add human approval to your AI agents.
Authentication
All API requests require a Bearer token in the Authorization header.
Authorization: Bearer ho_your_api_key_here
API keys start with ho_ and are shown only once at creation. Store them securely.
Quick Start
Get a human decision in three steps:
1. Create an API key
Sign in to the Dashboard with Google or GitHub, then click Create New Key. Copy the key immediately — it's only shown once.
POST /auth/api-keys using an OAuth token.2. Create a decision
curl -X POST https://thehandover.xyz/decisions \
-H "Authorization: Bearer ho_your_key" \
-H "Content-Type: application/json" \
-d '{
"action": "Send contract to Acme Corp for $48,000/year",
"context": "Customer requested enterprise plan after 3 sales calls.",
"approver": "sarah@company.com",
"urgency": "high",
"timeout_minutes": 120
}'import requests
resp = requests.post(
"https://thehandover.xyz/decisions",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"action": "Send contract to Acme Corp for $48,000/year",
"context": "Customer requested enterprise plan after 3 sales calls.",
"approver": "sarah@company.com",
"urgency": "high",
"timeout_minutes": 120,
},
)
decision = resp.json()
# decision["id"] -> use this to pollconst res = await fetch("https://thehandover.xyz/decisions", {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
action: "Send contract to Acme Corp for $48,000/year",
context: "Customer requested enterprise plan after 3 sales calls.",
approver: "sarah@company.com",
urgency: "high",
timeout_minutes: 120,
}),
});
const decision = await res.json();
// decision.id -> use this to poll
3. Poll for the result
curl https://thehandover.xyz/decisions/DECISION_ID \
-H "Authorization: Bearer ho_your_key"
# Response:
{
"id": "uuid",
"status": "approved",
"resolved_at": "2026-03-26T...",
"response_notes": null
}import time
while True:
resp = requests.get(
f"https://thehandover.xyz/decisions/{decision['id']}",
headers={"Authorization": f"Bearer {API_KEY}"},
)
result = resp.json()
if result["status"] != "pending":
break
time.sleep(5)
print(result["status"]) # "approved" | "denied" | "modified"const poll = async (id) => {
while (true) {
const r = await fetch(`https://thehandover.xyz/decisions/${id}`, {
headers: { "Authorization": `Bearer ${API_KEY}` },
});
const data = await r.json();
if (data.status !== "pending") return data;
await new Promise(r => setTimeout(r, 5000));
}
};
const result = await poll(decision.id);
// result.status -> "approved" | "denied" | "modified"
Create Decision
Creates a new decision request and notifies the approver.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
action | string | Yes | What the agent wants to do |
approver | string | Yes | Email of the person who should decide |
context | string | No | Additional context to help the approver decide |
urgency | string | No | low | medium | high | critical. Default: medium |
timeout_minutes | number | No | Minutes before decision expires. Default: 60 |
channel | string | No | email | webhook | slack. Default: email |
options | string[] | No | approve, deny, modify. Default: ["approve","deny","modify"] |
callback_url | string | No | URL to POST results to when resolved |
Response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"expires_at": "2026-03-25T14:30:00.000Z",
"created_at": "2026-03-25T12:30:00.000Z"
}
Get Decision
Returns the current status of a decision. Use this to poll for results.
Response
{
"id": "550e8400-...",
"action": "Send contract to Acme Corp",
"status": "approved",
"urgency": "high",
"resolved_at": "2026-03-25T13:15:00.000Z",
"resolved_by": "sarah@company.com"
}
Resolve Decision
Programmatically resolve a pending decision. Use for webhook channel or custom UIs.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
action | string | Yes | approve, deny, or modify |
notes | string | No | Additional notes |
resolved_by | string | No | Who resolved it. Defaults to approver email. |
Decision Lifecycle
Every decision follows this flow:
- pending — Created, approver notified. Waiting for response.
- approved — Approver approved the action.
- denied — Approver denied the action.
- modified — Approver requested changes (includes notes).
- expired — No response within timeout.
- escalated — Escalated to backup approver (Pro+).
Webhooks
Receive decision events without polling.
Webhook Channel
Set channel: "webhook" — we POST the decision to your callback_url instead of emailing. Resolve via POST /decisions/:id/resolve.
Callback URL
Provide callback_url with any channel to receive resolution notifications.
Payload
{
"event": "decision.resolved",
"decision": {
"id": "uuid",
"status": "approved",
"resolved_by": "sarah@company.com"
},
"timestamp": "2026-03-25T13:15:00.123Z"
}
Verifying Signatures
Every webhook includes an X-Handover-Signature header (HMAC-SHA256). Verify it:
import { createHmac } from 'crypto';
const expected = 'sha256=' + createHmac('sha256', secret)
.update(body).digest('hex');
return expected === signature;
Retries
Failed deliveries retry up to 3 times (immediate, 5s, 30s).
Slack Integration
Send decisions directly to Slack as interactive messages.
Setup
- Create a Slack app at
api.slack.com/apps - Bot Scopes:
chat:write,users:read,users:read.email,im:write - Set Interactivity URL to
YOUR_APP_URL/slack/interactions
Usage
{
"action": "Deploy v2.1 to production",
"approver": "sarah@company.com",
"channel": "slack",
"urgency": "high"
}
Rate Limits
The API enforces both per-minute and monthly rate limits to ensure fair usage.
Per-Minute Limits
| Endpoint | Requests | Window |
|---|---|---|
POST /decisions | 30 | 1 minute |
GET /decisions/* | 30 | 1 minute |
GET /d/* | 30 | 1 minute |
POST /auth/login/* | 10 | 1 minute |
GET /auth/me | 30 | 1 minute |
POST /auth/api-keys | 10 | 1 hour |
Monthly Decision Limits
| Tier | Decisions / Month |
|---|---|
| Free | 50 |
| Pro | 500 |
| Team | 2,000 |
Rate Limit Response
When you exceed a rate limit, the API returns 429 Too Many Requests:
{
"error": "Rate limit exceeded",
"code": "RATE_LIMIT"
}
The response includes a Retry-After header indicating when you can retry (in seconds). When your monthly limit is reached, you'll receive the same 429 response and will need to upgrade your plan or wait for the next billing cycle.
Errors
All errors return consistent JSON:
{ "error": "message", "code": "ERROR_CODE" }
| Code | Status | Description |
|---|---|---|
UNAUTHORIZED | 401 | Missing or invalid Authorization header |
INVALID_KEY | 401 | API key not found or inactive |
VALIDATION_ERROR | 400 | Missing or invalid request fields |
RATE_LIMIT | 429 | Monthly limit exceeded |
NOT_FOUND | 404 | Decision not found |
ALREADY_RESOLVED | 409 | Decision no longer pending |
EXPIRED | 410 | Decision timed out |
UNSUPPORTED_CHANNEL | 400 | Channel not available |