Embed human approval in your product
Embed human in the loop approval in your product. Connect an end user, create an approval, verify the webhook and resume your workflow only after a yes.
This is the full Partner guide for asking your users before your agent acts. Your backend picks who answers, creates a decision, and carries on when the answer comes back. Your user gets a push notification. Yes or no works from the lock screen. Choices and text open the app. A missing answer never counts as approval.
npm install @pushary/serverNew here? The builder quickstart covers the two calls most products need. This page covers the rest: callbacks, resuming later, sandbox keys and answers from your own app.
To get your own agents' questions on your phone instead, use the agent quickstart.
What you need
- A backend that owns the action and signs in your users.
- An active Partner or Enterprise workspace for live delivery. Partner is $99 a month, with a 3-day trial and a card required at checkout.
- A stable user id from your application, passed as
externalId. - A way to reach that user: the Pushary app, browser push notifications, a link you deliver yourself, or your own signed-in approval screen.
Pushary handles the decision, delivery, answer storage and callbacks. Your code must check the result before it runs the action, and save enough workflow state to resume after a restart. A tool the model may choose to call does not guard your other tools.
Try the loop before you integrate, or use the API sandbox to test without the Partner plan. The API sandbox needs a signed-in workspace with a site and an active or trialing subscription on any plan, the Agent plan included.
1. Set up your workspace and key
Start Partner signup, finish checkout, and follow onboarding to set your product name and logo. You can test delivery to your own phone during onboarding.
Onboarding issues a runtime key with agent scope. Use it to connect users and to create and read decisions. It cannot administer the site, answer for a customer, or return an approvalUrl.
Copy your full API key from Agent settings and store it as PUSHARY_API_KEY on your server. The full key looks like pk_xxx.xxx. The public pk_xxx prefix alone cannot call this API.
import { createPusharyServer } from "@pushary/server"
const pushary = createPusharyServer({
apiKey: process.env.PUSHARY_API_KEY!,
})Keep the full key out of browser code, prompts and logs. Your server takes the recipient from the signed-in session. For an agent runtime that serves one user, use a bound key. It can create, read and cancel that user's decisions, but it cannot answer them.
2. Connect the person who will approve
Run this on your backend for the signed-in user:
const enrollment = await pushary.enroll(user.id)
// Show enrollment.universalLink as a button or QR code in your app.Creating the link does not connect a device. The user must open it and agree.
| Channel | What the user does |
|---|---|
| Pushary app | Install the iPhone or Android app, open the link, and turn on approvals. An invited end user needs no Pushary account or subscription. |
| Browser push | Open the link in a supported browser and choose Enable browser notifications. On iOS this needs a Home Screen web app, which is separate from the native Pushary app. |
| Your email, SMS or inbox | Ask for an approvalUrl when you create the decision and send it through your own service. No push enrollment is needed. |
| Your own screen | Sign the user in and relay their answer from your backend. See headless answers. |
What your end user sees
When the app is installed, the link opens a consent screen with your name and logo. If they install the app after opening the link, the install does not pick up the enrollment. They can open the link again, or tap Invited by a company? in the app and scan the QR code from another screen. Keep the original link and QR code available until setup finishes.
Once connected, each approval arrives as a notification titled with your product name, such as Acme needs your approval. On iPhone, a second line names the agent from agentName, such as via Refund bot, when it differs from your product name. Android shows no second line. The notification icon is the Pushary app's own. Your logo appears on the consent screen and on the approval card inside the app, which also names your product and the agent.
The end user can disconnect from your product at any time in the app, without affecting other companies they are connected to. That phone then stops counting as a push channel for them. Send a new link to reconnect.
Links last 24 hours and can be redeemed once per channel (native app and browser push). Make a fresh link for another device or an expired invitation. Treat these personal links as credentials: whoever redeems one connects as that user.
If single-use storage is unavailable, redeeming returns HTTP 503 without connecting a device. Retry after the returned Retry-After delay.
Invited users do not sign in or pay. Sign in in the app starts the separate flow for people who use Pushary for their own agents.
Check reachability
const reach = await pushary.reachability(user.id)
// deliverability: "push" | "fallback" | "unreachable"deliverability | What it means |
|---|---|
push | An enrolled native device or active browser subscription exists. It does not confirm a notification was received. |
fallback | You named an end user, but Pushary has no confirmed push channel for them. Deliver an approval link yourself. |
unreachable | No recipient was named. |
Set requireReachable: true on a create to reject it with HTTP 409 and code: "unreachable" when the recipient has no registered push channel. The check does not count Slack or links you deliver yourself. An unavailable reachability read is not a delivery guarantee.
3. Create an approval and save its ID
Use create-and-resume for serverless functions and jobs that may restart. decisions.create() returns at once by default.
const decision = await pushary.decisions.create({
externalId: user.id,
question: "Publish this video to your public profile?",
type: "confirm",
toolName: "video.publish",
toolTarget: video.id,
context: runId,
idempotencyKey: `${runId}:publish:${video.id}`,
expiresInSeconds: 3600,
callbackUrl: "https://yourapp.com/webhooks/pushary",
})
// Save decision.decisionId against this run and its exact proposed action.
// Mark the run as waiting and return from the request.A new decision normally returns status: "pending" and answered: false. Handle whatever state comes back: a retry can return a decision that is already answered, and a stopped workspace can return status: "stopped".
Idempotency keys
Use the same idempotency key for retries of one operation, and a new key for a new operation. The deduplication window is 24 hours, scoped to your site and the recipient. Do not build the key from the question text alone, and do not reuse it for a changed action. decisions.ask() makes a fresh key for each call if you omit one, so it does not deduplicate a later call after your process restarts.
If idempotency storage is unavailable, a create with an idempotency key returns HTTP 503 instead of risking a duplicate decision. Honor Retry-After and retry with the same key. A retry after a failed insert can complete the originally reserved id. A reservation alone is never returned as a pending decision.
REST equivalent
curl --fail-with-body https://pushary.com/api/v1/server/decisions \
-H "Authorization: Bearer $PUSHARY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: run-42-publish-video-7" \
-d '{
"externalId": "user-123",
"question": "Publish this video to your public profile?",
"type": "confirm",
"toolName": "video.publish",
"toolTarget": "video-7",
"expiresInSeconds": 3600
}'Deliver the link over your own channel
Set approvalUrl: true and externalId on the create request. On a new decision, the response includes an approvalUrl bound to that decision, site and recipient. It needs a full-access key: the onboarding agent key gets no link back. The link expires after 12 hours or when the decision expires, whichever comes first. Store it securely if you send it later, because idempotent replays do not return it.
The link lets whoever holds it answer as the named recipient. Do not log it or treat it as proof of the person's identity. For sensitive actions, use your own signed-in screen. Pushary does not send your email or SMS for you.
4. Resume only after approval
Poll the saved decision id from a worker or scheduled job:
const state = await pushary.decisions.get(decisionId, { wait: 30 })
if (state.status === "answered" && state.value === "yes") {
// Atomically claim your saved workflow step, then execute the saved action.
} else if (state.status === "pending") {
// Keep the action paused; schedule another read of this same decision ID.
} else {
// Denied, expired, or cancelled: do not execute the action.
}This example is for type: "confirm". For select or input, check the returned value against what your workflow accepts. An answer is not permission to run any action.
Webhooks
A callback can wake your worker as soon as an answer arrives:
{
"correlationId": "the-decision-id",
"answer": "yes",
"value": "yes",
"answeredAt": "2026-09-04T12:00:00Z",
"context": "run-42"
}correlationId is the decisionId you saved. answer is canonical and value is an alias. context is optional and goes through secret redaction.
With an explicitly authorized administration credential, fetch the site's signing secret once with pushary.decisions.getWebhookSecret() and store its webhookSecret securely. In your callback handler, verify the raw request body before you parse it:
import { parseDecisionCallback, verifyWebhookSignature } from "@pushary/server"
const rawBody = await request.text()
if (!verifyWebhookSignature(
rawBody,
request.headers.get("x-pushary-signature"),
process.env.PUSHARY_WEBHOOK_SECRET!,
)) {
return new Response("Invalid signature", { status: 401 })
}
const callback = parseDecisionCallback(rawBody)
if (!callback) return new Response("Invalid callback", { status: 400 })
// Enqueue a read of callback.correlationId using your saved run mapping.
// Deduplicate execution in your workflow; callbacks can be retried.
return new Response(null, { status: 204 })Keep polling as a backup for missed callbacks and to discover expiry. A webhook alone does not restart your agent or guarantee the side effect runs once.
If nobody answers
The decision stays open for 1 hour by default. You can set anything from 60 seconds to 24 hours with expiresInSeconds. Expired and cancelled decisions are not approvals.
For a long-lived worker, decisions.ask() combines create and polling. Its default wait is 55 seconds. When it returns approved: false, the decision may still be pending. Save its id and poll later, or cancel it if you abandon the action. create({ wait: true }) holds one HTTP request for 30 seconds by default, capped at 55 seconds. Neither wait changes the decision's expiry.
Answer from your own app (headless)
Your backend must check that the signed-in user may answer this specific decision:
const result = await pushary.decisions.answer(decisionId, "yes")
// Read result.value: another surface may already have recorded "no".This endpoint needs a full-access key, called from your server. The runtime key from onboarding and bound keys cannot submit customer answers. A bound key gets a 403 with the code bound_key_cannot_answer. Pushary trusts the full-access key and records the answer as a partner relay. Keep that key in trusted server code, away from the model and the end user.
Per-end-user keys (multi-tenant)
Issue a recipient-bound key from your backend with a separately authorized administration credential. The onboarding runtime key cannot issue keys.
const session = await pushary.keys.issue({
externalId: user.id,
expiresInSeconds: 3600,
})
// Configure this user's trusted runtime with session.apiKey.
// When the session ends:
await pushary.keys.revoke(session.keyPrefix)A bound key forces its own recipient when creating decisions. Enrollment requires your full-access key on your trusted backend. It can create, read and cancel decisions for that one user, and cannot reach another user's decisions. It cannot answer a decision, not even one for its own user. The answer comes from the person, or from your full-access key on your server. A bound key does not replace signing the person in on your own screen.
Use MCP or a framework adapter
Framework adapters plug into your framework's own tool approval and durable workflow mechanisms.
A Partner or Enterprise MCP connection exposes enroll_end_user, create_decision, get_decision and cancel_decision. Use your full-access key on your trusted backend for enroll_end_user. Use a bound key in the agent runtime for the three decision tools and leave out the model-supplied externalId. Bound keys cannot enroll devices. None of these tools answers a decision. A connector-scoped key does not expose these tools. See the MCP reference.
Only decisions reach a phone connected through your link. ask_user, send_notification and POST /api/v1/server/ask also accept externalIds, but there they target browser push subscribers, never a phone connected through your link.
Let policy answer, and a person only when it cannot
Policy administration and the authorization endpoints need a credential that is explicitly authorized for them. The onboarding runtime key is for customer questions and decisions. It cannot administer rules or use these authorization calls. Keep the privileged client separate from the agent runtime.
decisions.ask() always asks a person. pushary.authorize() checks policy first and calls decisions.ask() only for requires_human. Check approved before you run an action. For serverless workflows, use evaluateAuthorization() and create a durable decision yourself when it returns requires_human.
Rules match toolName, optionally toolTarget, and scalar parameters. An unmatched action asks a person. A threshold rule that only requires approval above a limit does not allow everything below that limit. Add an explicit allow rule for that range.
For example, create these two rules with POST /api/v1/server/authorization-rules:
{
"toolPattern": "refund.create",
"effect": "allow",
"conditions": [{ "parameter": "amount", "operator": "lt", "value": 500 }]
}{
"toolPattern": "refund.create",
"effect": "require_approval",
"conditions": [{ "parameter": "amount", "operator": "gte", "value": 500 }]
}Use the same units in your rules and your calls. Among matching rules, deny wins over require_approval, which wins over allow. Missing or incompatible parameter values require a person. Conditions within one rule must all match. Operators are eq, neq, gt, gte, lt, lte, in and not_in.
If no parameter rule applies, named permission rules are checked, then an unmatched action returns requires_human. The agent wildcard * does not authorize business actions.
Label what the decision is about
Use toolName and toolTarget for a stable action and resource. actor names whose authority the action uses, and externalId names the approver. environment tells deployments apart. parameters takes up to 32 scalar values (strings, finite numbers or booleans). Keep secrets out of every field.
These fields describe the proposed action for audit filtering and policy checks. Your execution code must run the same action with the same values that were approved.
Say what the action does, not what the tool is called
Send presentation on decisions.create() or decisions.ask() to describe the effect in business language. Each change names a key in parameters, so the value shown comes from the same data a rule reads:
await pushary.decisions.create({
externalId: user.id,
question: "Approve a EUR 48.00 refund?",
toolName: "refund.create",
parameters: { amount: 4800 },
idempotencyKey: `${runId}:refund:${order.id}`,
presentation: {
label: "Refund this order",
effect: "Returns EUR 48.00 to the customer's card.",
changes: [{
parameter: "amount",
label: "Refund amount",
format: { kind: "currency", currency: "EUR" },
}],
},
})Currency values use integer minor units, so 4800 is EUR 48.00. The other formats are quantity, timestamp, boolean and text. An invalid presentation returns HTTP 400. Without a presentation, the plain question still shows.
Run it once, and prove it ran
For execution receipts and a single-use permit, use protect():
import { createAdapterKernel } from "@pushary/server/adapters"
const kernel = createAdapterKernel("my-product")
const protect = kernel.protect({ apiKey: process.env.PUSHARY_API_KEY! })
const outcome = await protect({
action: "refund.create",
target: order.id,
externalId: user.id,
facts: { amount: order.amountMinor, currency: "EUR" },
callId: toolCall.id,
runId: run.id,
run: () => issueRefund(order),
})
if (!outcome.ok) return outcome.reasonThe permit binds the authorization to the action, target, actor, environment, recipient and facts. A second claim of the same authorization is refused. Keep workflow ids stable and use downstream idempotency where the provider supports it. A new authorization is a new permit, and Pushary cannot make an external API call exactly-once.
protect() tries to record success or failure. If the process dies or receipt storage fails, the permit can stay running. Reconcile the downstream outcome before you retry. Errors thrown by run reach your code.
For a custom executor, call POST /api/v1/server/authorizations/consume with the authorization id and the exact action, then POST /api/v1/server/authorizations/{permitId}/receipt with the outcome. Refusals are not_authorized, action_mismatch, expired and already_consumed.
Send customer approvals to Slack
Connect Slack in your workspace's integrations. Slack and push deliver in parallel, not as an ordered fallback chain. Check that the Slack audience is right before you send customer approvals there.
| Decision | Where Slack sends it |
|---|---|
No externalId and no email (owner or team decision) | The configured shared channel, or the member DM set on the integration |
email set | A DM to the Slack member with that email |
externalId set but no email | Not sent to Slack |
Create customer decisions with your server-side Partner key, the customer's externalId and their Slack email. Pushary looks up that email in the connected Slack workspace and saves the member and DM channel before it posts. If the member cannot be found, Pushary does not post the request to the shared channel. Temporary Slack failures are retried. Enrolled phone and browser delivery work independently.
A key bound to an end user cannot supply a Slack email. Use that key with the customer's enrolled phone or browser, and keep Slack recipient selection in your trusted server code.
Slack answers must come from the saved member, workspace and channel. Shared channel answers stay available for owner and team decisions without a customer recipient. A choice or written answer is data, not permission to run an action.
Older Slack cards created before recipient pinning cannot prove their recipient and are refused. Answer those requests in the app or browser, or cancel and recreate them. New cards keep their saved destination when another surface answers, even if the default Slack channel later changes.
Test and operate
Test your integration walks through approval, denial, unanswered decisions, retries and real delivery. Pricing and limits covers the accepted-request and legacy delivery allowances, API limits and decision lifetime.
Next steps
Test your integration
Sandbox keys, retries and live delivery checks before you turn on real actions.
Use a framework adapter
Vercel AI SDK, LangGraph, OpenAI Agents SDK, Mastra, Claude Agent SDK and more.