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 delivers it through connected channels.
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.
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.
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.
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".Pushary trusts your server key and records this 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 your full Partner key:
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.
Let policy answer, and a person only when it cannot
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 notification allowance, 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.