Embed human approval in your product
Pause your own AI agent, ask your end-user to approve, and resume on their answer, with a typed SDK, verifiable webhooks, and an audit trail
The rest of these docs are about connecting your own agent to your own phone. This page is different: it is for teams building an AI product who want to pause their agent and ask their end-user to approve a consequential action, then resume on the answer. You keep your agent and your notification channel, and add one call. It runs on the Partner plan.
Building on a framework like the Vercel AI SDK or Eve? Framework adapters give you one tool plus one enroll call.
A human decision is asynchronous. It takes seconds, sometimes hours, so it is not
a normal function call. If your agent runs in a serverless function (Vercel,
Lambda, Cloud Run) you must use create-and-resume, not block-and-wait. Do not try
to await a human for 20 minutes inside a Lambda.
Two ways to wire it in
- Block and wait — call create with
wait: true; it holds the connection up to ~55s. Good for a long-lived agent (a desktop CLI agent, a worker you control). - Create and resume — create the decision, get an id back immediately, let your agent yield, and resume from a webhook or by polling. Use this for serverless.
Setup (once)
-
Get a Partner API key from your dashboard (
pk_xxx.sk_xxx). -
Connect the human on at least one channel. This is what makes them reachable, and you can offer more than one:
- Pushary app (recommended): call
enroll(externalId)and hand the user the returned link. One tap connects their phone (iPhone or Android) and approvals arrive on the lock screen. No account, no password. - Browser web push: the same link lets them turn on browser notifications, or drop the browser SDK into your web app. Works on desktop and Android; on iPhone, web push needs the app installed.
- Slack (to your team): connect your Slack workspace in the dashboard to route approvals to a team channel in parallel.
You deliver
enroll(externalId)'suniversalLinkthrough your own channel (email, SMS, or an in-app button), or generate one from the dashboard.POST /api/v1/identifyis not a channel: it only attaches anexternalIdto a browser that already subscribed to Pushary web push. - Pushary app (recommended): call
Quick start (SDK)
npm install @pushary/serverimport { createPusharyServer, verifyWebhookSignature } from "@pushary/server"
const pushary = createPusharyServer({ apiKey: process.env.PUSHARY_API_KEY! })
// Create (async by default). Always pass an idempotencyKey.
const decision = await pushary.decisions.create({
externalId: user.id,
email: user.email, // optional: DMs this person in Slack, if connected
question: "Publish this generated video to your public profile?",
type: "confirm", // confirm | select | input
callbackUrl: "https://yourapp.com/webhooks/pushary",
idempotencyKey: `run-${runId}-step-3`,
expiresInSeconds: 3600, // how long the human has (default 1h, max 24h)
})
// Resume from the webhook, or poll durably.
const state = await pushary.decisions.get(decision.decisionId, { wait: 30 })
if (state.answered) resume(state.value)The same flow in Python (pip install pushary):
import os
from pushary import PusharyServer, verify_webhook_signature
pushary = PusharyServer(api_key=os.environ["PUSHARY_API_KEY"])
# Create (async by default). Always pass an idempotency_key.
decision = pushary.decisions.create(
"Publish this generated video to your public profile?",
type="confirm", # confirm | select | input
external_id=user.id,
email=user.email, # optional: DMs this person in Slack, if connected
callback_url="https://yourapp.com/webhooks/pushary",
idempotency_key=f"run-{run_id}-step-3",
expires_in_seconds=3600, # how long the human has (default 1h, max 24h)
)
# Resume from the webhook, or poll durably.
state = pushary.decisions.get(decision["decisionId"], wait=30)
if state["answered"]:
resume(state["value"])Or call the REST API directly
curl -X POST https://pushary.com/api/v1/server/decisions \
-H "Authorization: Bearer pk_xxx.sk_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: run-8f2a-step-3" \
-d '{
"externalId": "your-user-id-123",
"email": "approver@acme.com",
"question": "Publish this generated video to your public profile?",
"type": "confirm",
"callbackUrl": "https://yourapp.com/webhooks/pushary",
"expiresInSeconds": 3600
}'{ "decisionId": "b1c9...", "status": "pending", "answered": false, "pollUrl": "https://pushary.com/api/v1/server/decisions/b1c9...", "expiresInSeconds": 3600 }Make it a tool your agent can call
The decision call is just HTTP, so it becomes a tool in whatever your agent uses.
When the model calls the tool, your code runs the call. The model proposes the
question; your server attaches who to ask (externalId) and your API key. Never
let the model choose the recipient or hold the key.
Anthropic (Claude) / OpenAI (Codex)
Define a tool named e.g. ask_human_to_approve with a question (and optional
options) input. In your handler, inject the current user's externalId and call
decisions.create. For a serverless agent, return a pending result and resume
the run when the webhook fires; for a long-lived agent, call with wait: true and
return the answer inline.
Any MCP agent
If your agent speaks MCP, connect the Pushary MCP endpoint to get an ask-a-human
tool with no code, passing the end-user's externalId as an argument.
Resume: get the answer back
Webhook (fast path). On answer, Pushary POSTs your callbackUrl:
{
"correlationId": "b1c9...",
"answer": "yes",
"value": "yes",
"answeredAt": "2026-07-12T18:03:00Z",
"context": "run_42"
}Read answer (canonical; value is an alias) keyed by correlationId. context
echoes back whatever string you passed at create time, so a stateless handler can
carry its own run/step id through the callback without a correlationId-to-state
map. context is present only when you set it; older integrations that read just
answer keep working.
Verify it. X-Pushary-Signature is HMAC-SHA256 of the raw body with your own
webhook secret. Fetch it once (decisions.getWebhookSecret()), cache it, and check
with verifyWebhookSignature(rawBody, header, secret) — or parseDecisionCallback(rawBody)
after verifying. Rotate with decisions.rotateWebhookSecret().
Poll (fallback). GET /decisions/{id}?wait=30 long-polls up to N seconds and
reads durably, so it resolves even after the live window closes.
Always implement poll as well as webhook. Webhook for speed, poll so a dropped delivery never strands a run.
Answer from your own app (headless)
If you render the approval in your own authenticated UI instead of letting Pushary deliver it, relay the user's choice from your backend:
await pushary.decisions.answer(decisionId, "yes") // confirm: yes/no; select: the option; input: textIt resolves the decision exactly as the hosted page would (fires your webhook, unblocks a poll, writes the audit record). You authenticate the user; we trust your key.
Know if it will reach them
A decision addressed to an end-user returns a reachability signal so you can tell "no human was reachable" apart from "the human declined":
{ "decisionId": "b1c9...", "status": "pending", "reachable": true, "reachableChannels": 2, "deviceCount": 3 }reachable: false means that externalId has no enrolled device and no push
subscription, so the decision would expire unanswered. Enroll them first, or pass
requireReachable: true to have create / ask refuse with a 409 (code: "unreachable") instead of opening a decision that reaches nobody:
const r = await pushary.decisions.ask({
externalId: user.id,
question: "Approve this refund?",
requireReachable: true, // throws "unreachable" rather than asking a ghost
})Per-end-user keys (multi-tenant)
If you drive one shared agent for many end-users, mint a key bound to a single end-user for that session, then hand it to the agent. A bound key can only create or resolve decisions and enroll that exact user, so a prompt-injected agent acting for user A can never reach user B, even over the raw API or MCP.
// On your backend, with your own (unbound) Partner key:
const session = await pushary.keys.issue({ externalId: user.id, expiresInSeconds: 3600 })
// Hand session.apiKey to the agent runtime for this user. It is shown once.
// ...when the session ends:
await pushary.keys.revoke(session.keyPrefix)With a bound key, externalId is implied everywhere: omit it on create_decision
and enroll, and any value the agent supplies is ignored in favor of the bound one.
If nobody answers
The decision expires at expiresInSeconds. Your workflow decides what that means
(proceed, block, escalate). Handle the expired status; never hang a run forever.
What people get wrong
- Blocking a serverless agent on a human. It times out. Use create-and-resume.
- Reachability is a real step. No connected channel, no ask. A decision for a
user who never enrolled a phone, subscribed to web push, or has no Slack route
opens, reaches nobody, and expires unanswered. Enroll them first, or pass
requireReachable: trueto fail fast with a 409 instead of asking a ghost. - Who is answering? The hosted page link is bearer auth. For anything sensitive, have the user answer inside your own authenticated app and relay it.
- Verify webhooks. Never act on an unverified callback.
- Approval fatigue. Ask only for consequential actions; auto-allow the rest.
- Idempotency is not optional. Agents retry.
decisions.ask()generates a key for you, but the rawdecisions.create()and the MCPcreate_decisiontool do not — you must pass your own stableidempotencyKey(orIdempotency-Keyheader) on those paths, or a retried create double-asks the same human.
Limits
Partner plan: up to 25,000 end-users and 100,000 notifications/month, 365-day retention on the decision log. A decision stays open up to 24h; up to 200 pending at once per site.