API

Handoff API

Four endpoints. One of them bills; the rest are free. Base URL https://handoff.ounie.com.

Quick start

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" }
Authentication

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 as Authorization: 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.
Every call spends only the key owner's own Ounie credits, and the reserve happens before the send — so an agent that loops can be refused with a 402, but it can never overdraw.
POST /api/handoff

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.

FieldTypeNotes
channelstringemail | telegram
tostringemail: an address · telegram: a numeric chat id or @username
subjectstring?One-line headline shown above the message
messagestringWhat the human needs to know (≤ 4000 chars)
kindstring?approve (default, has buttons) | notify (one-way, no link)
choicesstring[]?Buttons in order, first is primary (≤ 5; default Approve, Reject)
expiresInMinnumber?5–43200, default 1440
context.agentstring?Who is asking, shown to the human
context.runUrlstring?https link for context; rendered as a link on the page
The decision link is not returned to agents.A response to a Bearer key, an MCP host or an x402 payer carries the request id and a poll URL, never the human's one-time link. Only a call made with a signed-in browser session gets 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.

GET /api/handoff/{id} free

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.

List and cancel
GET    /api/handoff?status=pending&limit=25     # free
DELETE /api/handoff/{id}                        # free — withdraw an unanswered request

Cancelling 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.

The handoff object
{
  "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"
}
ValueFieldMeans
pendingstatusDelivered; a human has it and hasn't answered
deliveredstatusA notify — one-way, nothing to wait for
answeredstatusanswer holds exactly one of your choices
expiredstatusThe deadline passed. Nothing was approved
cancelledstatusYou withdrew it
failedstatusThe provider refused it. You were refunded
The decision page

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, noindex and Referrer-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.
MCP server
{
  "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_…

ToolCostWhat it does
request_approval2 crAsk a human to approve or reject, get a request id
notify_human2 crOne-way message, no answer expected
wait_for_answerfreeBlock until the human answers, expires, or your timeout
get_handofffreeRead one request
list_handoffsfreeRead many
cancel_handofffreeWithdraw an unanswered request
get_credit_balancefreeSpendable Ounie credits
get_pricingfreePublic — no auth needed
whoamifreeWhich account this key belongs to
Out of credits, a tool returns insufficient_credits with a link to top up. It never partially sends, and it never overdraws.
POST /api/x402/handoff

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.
Errors
StatusBodyMeans
400invalid_payload · invalid_email · message_too_longFix the request; nothing was charged
401unauthorizedMissing or bad credential
402insufficient_creditsTop up at https://ounie.com/dashboard/settings
404not_foundUnknown id, or a poll key that doesn't match
409not_cancellableAlready answered, expired, or not yours
429too_many_pendingOver 100 unanswered requests
502delivery_failed (refunded: true)The provider refused it while you waited. Full refund already applied
503channel_unavailableThat channel isn't configured on this deployment