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.

Tip: You can also create keys programmatically via 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 poll
const 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

POST /decisions

Creates a new decision request and notifies the approver.

Request Body

FieldTypeRequiredDescription
actionstringYesWhat the agent wants to do
approverstringYesEmail of the person who should decide
contextstringNoAdditional context to help the approver decide
urgencystringNolow | medium | high | critical. Default: medium
timeout_minutesnumberNoMinutes before decision expires. Default: 60
channelstringNoemail | webhook | slack. Default: email
optionsstring[]Noapprove, deny, modify. Default: ["approve","deny","modify"]
callback_urlstringNoURL 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

GET /decisions/:id

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

POST /decisions/:id/resolve

Programmatically resolve a pending decision. Use for webhook channel or custom UIs.

Request Body

FieldTypeRequiredDescription
actionstringYesapprove, deny, or modify
notesstringNoAdditional notes
resolved_bystringNoWho resolved it. Defaults to approver email.

Decision Lifecycle

Every decision follows this flow:

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

Usage

{
  "action": "Deploy v2.1 to production",
  "approver": "sarah@company.com",
  "channel": "slack",
  "urgency": "high"
}
The approver's email must match their Slack profile email. The app must be installed in the workspace.

Rate Limits

The API enforces both per-minute and monthly rate limits to ensure fair usage.

Per-Minute Limits

EndpointRequestsWindow
POST /decisions301 minute
GET /decisions/*301 minute
GET /d/*301 minute
POST /auth/login/*101 minute
GET /auth/me301 minute
POST /auth/api-keys101 hour

Monthly Decision Limits

TierDecisions / Month
Free50
Pro500
Team2,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" }
CodeStatusDescription
UNAUTHORIZED401Missing or invalid Authorization header
INVALID_KEY401API key not found or inactive
VALIDATION_ERROR400Missing or invalid request fields
RATE_LIMIT429Monthly limit exceeded
NOT_FOUND404Decision not found
ALREADY_RESOLVED409Decision no longer pending
EXPIRED410Decision timed out
UNSUPPORTED_CHANNEL400Channel not available