Handoff API
Four endpoints. One of them bills; the rest are free. Base URL https://handoff.ounie.com.
Send an approval request, then poll it until a human answers. The create call costs 2 credits; the poll costs nothing.
# 1. ask
curl -X POST https://handoff.ounie.com/api/handoff \
-H "Authorization: Bearer hnd_live_…" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"to": "ops@acme.com",
"subject": "Refund $840 to order 1183?",
"message": "Customer says it arrived broken. Photos are on the ticket.",
"kind": "approve",
"choices": ["Approve refund", "Ask for photos", "Deny"],
"expiresInMin": 240,
"context": { "agent": "Support bot", "runUrl": "https://acme.com/runs/91" }
}'
# → { "id": "8f3c…", "status": "pending",
# "poll_url": "https://handoff.ounie.com/api/handoff/8f3c…?key=…",
# "credits_charged": 2 }
# 2. wait
curl "https://handoff.ounie.com/api/handoff/8f3c…?key=…"
# → { "status": "answered", "answer": "Approve refund",
# "answered_at": "2026-08-22T14:02:11Z" }Three credentials are accepted, in this order:
- Your Ounie session cookie — how the dashboard talks to the API. Nothing to configure.
- An app key,
hnd_live_…— minted at Dashboard → API keys, shown once, up to 5 active. Send it asAuthorization: Bearer …or, when your client can't set headers, as?api_key=. - The Ounie fleet master key,
ounie_live_…— your ounie.com developer key, once you enable “Use across Ounie apps” at ounie.com → Settings → API keys. One key, the whole fleet.
Delivers one message and returns immediately. Bills 2 credits when the provider accepts the message; one the provider refuses while you wait is refunded in full and answers 502. An email accepted and bounced later is asynchronous and invisible to this call, so it bills and then expires unanswered.
| Field | Type | Notes |
|---|---|---|
| channel | string | email | telegram |
| to | string | email: an address · telegram: a numeric chat id or @username |
| subject | string? | One-line headline shown above the message |
| message | string | What the human needs to know (≤ 4000 chars) |
| kind | string? | approve (default, has buttons) | notify (one-way, no link) |
| choices | string[]? | Buttons in order, first is primary (≤ 5; default Approve, Reject) |
| expiresInMin | number? | 5–43200, default 1440 |
| context.agent | string? | Who is asking, shown to the human |
| context.runUrl | string? | https link for context; rendered as a link on the page |
decision_url back — because a workflow that can open its own approval page is not asking anybody anything, and that separation is the entire product.A telegram recipient must have messaged the bot at least once. Telegram forbids bots opening a conversation; if they haven't, the send fails with chat not found and refunds.
Returns the handoff object. Authenticate with your key, or with the ?key= poll token from the create response — that token is how a keyless x402 caller reads its own request back.
curl "https://handoff.ounie.com/api/handoff/8f3c…?key=<poll_token>"Poll as often as you like. A request past its deadline reports expired on the next read whether or not the sweep has run — the deadline is the contract, not the cron.
GET /api/handoff?status=pending&limit=25 # free
DELETE /api/handoff/{id} # free — withdraw an unanswered requestCancelling shows the human a “this was withdrawn” page instead of live buttons. It does not un-send the message — nobody can — but the decision it offered is dead. Up to 100 unanswered requests per account at once.
{
"id": "8f3c1a…",
"status": "answered", // pending | delivered | answered | expired | cancelled | failed
"channel": "email",
"kind": "approve",
"to": "ops@acme.com",
"subject": "Refund $840 to order 1183?",
"message": "Customer says it arrived broken.",
"choices": ["Approve refund", "Ask for photos", "Deny"],
"answer": "Approve refund",
"answered_at": "2026-08-22T14:02:11Z",
"expires_at": "2026-08-22T18:00:00Z",
"delivered_at":"2026-08-22T14:00:03Z",
"delivery_error": null,
"agent": "Support bot",
"run_url": "https://acme.com/runs/91",
"source": "api",
"credits_spent": 2,
"created_at": "2026-08-22T14:00:02Z"
}| Value | Field | Means |
|---|---|---|
| pending | status | Delivered; a human has it and hasn't answered |
| delivered | status | A notify — one-way, nothing to wait for |
| answered | status | answer holds exactly one of your choices |
| expired | status | The deadline passed. Nothing was approved |
| cancelled | status | You withdrew it |
| failed | status | The provider refused it. You were refunded |
The link in the message opens /r/<token> — a self-contained page with no JavaScript, no cookie and no sign-in. The buttons are plain form posts.
- Single-use. The answer is written by a conditional update that only matches a pending, unexpired request, so a double tap, a refresh, a back button or a link scanner all land on the answer already recorded — nothing is decided twice.
- Unguessable. 256 bits of CSPRNG entropy, stored only as a sha256 digest. A database read cannot approve anything.
- Non-leaking. Served
no-store,noindexandReferrer-Policy: no-referrer, so the token never rides along to your run URL. - Bounded. Only a choice the request actually offered can be written into the answer.
{
"mcpServers": {
"handoff": {
"type": "http",
"url": "https://handoff.ounie.com/api/mcp",
"headers": { "Authorization": "Bearer hnd_live_…" }
}
}
}Hosts that can't set static headers — the Ounie AI Team's manual MCP entry among them — put the key in the URL instead: https://handoff.ounie.com/api/mcp?api_key=hnd_live_…
| Tool | Cost | What it does |
|---|---|---|
| request_approval | 2 cr | Ask a human to approve or reject, get a request id |
| notify_human | 2 cr | One-way message, no answer expected |
| wait_for_answer | free | Block until the human answers, expires, or your timeout |
| get_handoff | free | Read one request |
| list_handoffs | free | Read many |
| cancel_handoff | free | Withdraw an unanswered request |
| get_credit_balance | free | Spendable Ounie credits |
| get_pricing | free | Public — no auth needed |
| whoami | free | Which account this key belongs to |
insufficient_credits with a link to top up. It never partially sends, and it never overdraws.For agents with no Ounie account: pay 0.024 USDC on Base per request, keyless. Same body as the credit endpoint.
POST /api/x402/handoff → 402 Payment Required + terms
POST /api/x402/handoff → 200
X-Payment: <signed authorization>
# → { "id": "…", "status": "pending",
# "poll_url": "https://handoff.ounie.com/api/handoff/…?key=…",
# "payment": { "settled": true, "tx_hash": "0x…" } }- Verify, deliver, then settle. An x402 settlement is final on chain and has no refund, so the payment is checked, the message is sent, and only then is the money taken. A delivery the provider refuses answers 502 and settles nothing.
- The poll URL needs no account. It carries its own read token — that is how a keyless caller reads its own answer back, for free.
- Network is Base, asset is USDC. The 402 body carries the exact terms including the USDC EIP-712 domain to sign against.
| Status | Body | Means |
|---|---|---|
| 400 | invalid_payload · invalid_email · message_too_long | Fix the request; nothing was charged |
| 401 | unauthorized | Missing or bad credential |
| 402 | insufficient_credits | Top up at https://ounie.com/dashboard/settings |
| 404 | not_found | Unknown id, or a poll key that doesn't match |
| 409 | not_cancellable | Already answered, expired, or not yours |
| 429 | too_many_pending | Over 100 unanswered requests |
| 502 | delivery_failed (refunded: true) | The provider refused it while you waited. Full refund already applied |
| 503 | channel_unavailable | That channel isn't configured on this deployment |