Embed human approval in your product
Set up the Partner plan, connect an end-user, create an approval, and safely resume your workflow.
Use the Partner integration when your product's agent needs approval from your customer. Your backend chooses the recipient, pauses the action, and resumes it after approval. Pushary stores the decision and reaches the customer in the native app. Confirm notifications can offer quick actions; choices and text open the app. Existing web and other supported delivery paths remain available.
If you want approvals for your own coding agent, start with the
agent quickstart. That uses ask_user; this integration
uses the durable Decisions API.
What you need
- A backend that owns the action and authenticates your users.
- An active Partner or Enterprise workspace for live delivery. Partner is $99/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, supported browser push, a link you deliver yourself, or your own authenticated approval UI.
Pushary handles the decision, delivery, answer storage, and callbacks. Your code must enforce the result before executing the action and persist enough workflow state to resume after a restart. Adding a tool the model may choose to call does not automatically guard your other tools.
Try the loop before integrating, or use the API sandbox to test without a paid plan. The API sandbox requires a signed-in workspace with a site already configured.
1. Set up your workspace and key
Start Partner signup, complete checkout, and follow onboarding to set your product name and logo. You can test delivery to your own phone during onboarding.
The Partner plan and key scope are separate: onboarding issues a runtime
(agent scope) key. Use it for enrollment and decision creation/reads; it cannot
administer the site or answer on behalf of the customer.
Copy your full API key from Agent settings. Store it
as PUSHARY_API_KEY on your server. The full key looks like pk_xxx.sk_xxx;
the public pk_xxx prefix alone cannot call this API.
npm install @pushary/serverimport { 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 gets the recipient from the authenticated session. For an agent runtime serving one user, use a bound key.
2. Connect the person who will approve
Run this on your backend for the authenticated 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 consent.
| Channel | User setup |
|---|---|
| Pushary app | Install the iPhone or Android app, open the link, and turn on approvals. No Pushary account or subscription is required for an invited end-user. |
| Browser push | Open the link in a supported browser and choose Enable browser notifications. On iOS, web push requires a Home Screen web app; it is separate from installing the native Pushary app. |
| Your email, SMS, or inbox | Request an approvalUrl when creating the decision and deliver it through your own service. No push enrollment is needed. |
| Your own UI | Authenticate the user and relay their answer from your backend. See headless answers. |
What your end-user sees
The link opens a consent screen with your name and logo when the app is installed. If they install the app after opening the link, the install does not automatically resume enrollment. They can open the link again, or tap Invited by a company? in the app and scan the QR from another screen. Keep the original link and QR 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 web push). Generate 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, enrollment returns HTTP 503 without
connecting a device. Retry after the returned Retry-After delay.
Invited users do not sign in or pay. Sign in starts the separate flow for people using Pushary for their own agents.
Check reachability
const reach = await pushary.reachability(user.id)
// deliverability: "push" | "fallback" | "unreachable"push means an enrolled native device or active browser subscription exists.
It does not confirm receipt of a notification. fallback means you supplied an
end-user ID but Pushary has no confirmed push channel; deliver an approval link
yourself. unreachable means no recipient was named.
requireReachable: true rejects a create 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, and 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 immediately 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",
})
// Persist 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 the returned state: a retry can return an already answered decision, and
a stopped workspace can return status: "stopped".
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 site and recipient.
Do not derive the key from question text alone or reuse it for a changed action.
decisions.ask() generates a fresh key per invocation if omitted; that does not
deduplicate a later invocation after your process restarts.
If idempotency storage is unavailable, a create with an idempotency key returns
HTTP 503 rather than risking duplicate decisions. Honor Retry-After and retry
with the same key. A retry after a failed insert can complete the original
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 expires after 12 hours or at decision expiry, whichever is sooner. Store it
securely if you need to send it later; idempotent replays do not return that link.
The link authorizes whoever holds it to answer as the named recipient. Avoid logging it or treating it as proof of the person's identity. For sensitive actions, use your own authenticated UI. Pushary does not send your email or SMS for you.
4. Resume only after approval
Poll the stored 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, validate the
returned value against what your workflow accepts. An answer is not automatically
permission to execute 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;
value is an alias. context is optional and is subject to secret redaction.
Use an explicitly authorized administration credential to 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 parsing:
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 recovery 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, configurable from 60 seconds to 24 hours. Expired and cancelled decisions are not approvals.
For a long-lived worker, decisions.ask() combines create and polling. Its
default wait budget is 55 seconds; returning approved: false can mean the
decision is still pending. Save its ID and poll later, or cancel it if the action
is abandoned. 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 verify 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 requires an explicitly authorized relay credential; the runtime key issued during onboarding cannot submit customer answers. Pushary trusts the relay credential and records the answer as a partner relay. A bound key limits the recipient, but still has the ability to submit an answer. Keep it in trusted execution code; do not expose it to the model or the end-user.
Per-end-user keys (multi-tenant)
Issue a recipient-bound key from your backend using 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 on create and enrollment, and cannot read or answer another recipient's decisions. It is not a substitute for authenticating the human in your own UI.
Use MCP or a framework adapter
Framework adapters integrate with existing tool execution and durable workflow mechanisms.
A Partner or Enterprise MCP connection exposes enroll_end_user,
create_decision, get_decision, and cancel_decision.
Use a bound key in the trusted runtime and omit the model-supplied externalId.
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 require an explicitly authorized credential with those capabilities. 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() evaluates policy
first and calls decisions.ask() only for requires_human.
Check approved before running 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 automatically 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 consistent units in your rules and calls. Among matching authorization rules,
deny wins over require_approval, then allow. Missing or incompatible
parameter values require a person. Conditions within a rule are ANDed.
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 coding-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; externalId names the approver.
environment distinguishes deployments. parameters accepts up to 32 scalar
values (strings, finite numbers, or booleans). Keep secrets out of all fields.
These fields describe the proposed action and support audit filtering and policy evaluation. Your execution code must use the same action and 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 its
displayed value comes from the same data:
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. Other formats are quantity,
timestamp, boolean, and text. Invalid presentations return HTTP 400.
Without a presentation, the free-text question is still available.
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 supported: a new authorization is a new permit, and Pushary cannot make an external API transaction exactly-once.
protect() attempts to record success or failure. If the process dies or receipt
storage fails, the permit can remain running; reconcile the downstream outcome
before retrying. Errors thrown by run propagate to your code.
For a custom executor, call POST /api/v1/server/authorizations/consume with the
authorization ID and exact action, then
POST /api/v1/server/authorizations/{permitId}/receipt with the outcome.
Refusals include not_authorized, action_mismatch, expired, and
already_consumed.
Test and operate
Use Test your integration to exercise approval, denial, unanswered decisions, retries, and real delivery. Pricing and limits covers the accepted-request and legacy delivery allowances, API limits, and decision lifetime.
Slack delivery is configured in your workspace's integrations. With an
email, Pushary attempts a DM to that Slack member; lookup or DM failure falls
back to the configured shared channel. Without email, it uses that channel.
Check that this audience is appropriate before connecting customer approvals to
Slack. Push channels and Slack may deliver in parallel; this is not an ordered
fallback chain.
Customer approvals in Slack
Create customer decisions with your server-side Partner key and the intended
customer's externalId and Slack email. Pushary resolves that email in the
connected Slack workspace and saves the member and DM channel before posting.
If the member cannot be found, it does not send the request to the shared channel.
Temporary Slack failures are retried; enrolled mobile and browser delivery remain
available independently.
A key bound to an end user cannot supply a Slack email. Use that key with the customer's enrolled mobile or browser connection; keep Slack recipient selection in your trusted server integration.
Slack answers must come from the saved member, workspace and channel. Shared channel answers remain available for owner/team decisions without a customer recipient. A choice or written answer is data, not permission to execute an action.
Older Slack cards created before recipient pinning cannot prove their recipient and are refused. Answer those requests through the existing authenticated app or browser flow, or cancel and recreate them. New cards use the saved destination when another surface answers, even if the default Slack channel later changes.