# Embed human approval in your product

Source: https://pushary.com/docs/agents/embed
Last updated: 2026-10-19

> 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. On iPhone, yes or no works from the lock screen. Android approvals, choices and text open the app. A missing answer never counts as approval.

```bash
npm install @pushary/server
```

For a complete customer journey, read the [enrollment, approval and resume walkthrough](/blog/what-an-external-reviewer-needs-to-decide).

New here? The [builder quickstart](/docs/agents/build/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](/docs/agents/quickstart).

## What you need [#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](/try) before you integrate, or use the [API sandbox](/docs/agents/embed-testing#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 [#1-set-up-your-workspace-and-key]

Start [Partner signup](/sign-up?from=agent\&plan=partner_launch), 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 create and read decisions. It cannot enroll users, administer the site, answer for a customer, or return an `approvalUrl`. Enrollment needs a full-access key on your trusted backend. Keep that privileged client separate from the agent runtime.

Copy your full API key from [Agent settings](/dashboard/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.

```ts
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](#per-end-user-keys-multi-tenant). It can create, read and cancel that user's decisions, but it cannot answer them.

## 2. Connect the person who will approve [#2-connect-the-person-who-will-approve]

Run this on your backend for the signed-in user:

```ts
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](#answer-from-your-own-app-headless).                                                    |

### What your end user sees [#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 &#x2A;*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 [#check-reachability]

```ts
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 [#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.

```ts
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 [#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 [#rest-equivalent]

```bash
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 [#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 [#4-resume-only-after-approval]

Poll the saved decision id from a worker or scheduled job:

```ts
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 [#webhooks]

A callback can wake your worker as soon as an answer arrives:

```json
{
  "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 on your server.

Verify the raw body with `verifyWebhookSignature` before calling `parseDecisionCallback`, both from `@pushary/server`. Do not parse and reserialize the JSON before verification. The `X-Pushary-Signature` header is the hex HMAC-SHA256 of that exact body.

Look up `correlationId` in your saved decision-to-operation mapping. Do not trust callback `context` to select a customer or action. Persist a job to recheck the decision before returning a success response. Duplicate delivery must not enqueue the action twice, and a storage failure must return a retryable error.

The [runnable webhook receiver and tests](/blog/verify-agent-approval-webhooks) demonstrate this with a durable local inbox. The worker then retrieves the decision and checks the saved customer, status, type and answer before acting. The receiver itself grants no approval.

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 [#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) [#answer-from-your-own-app-headless]

Your backend must check that the signed-in user may answer this specific decision:

```ts
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) [#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.

```ts
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 [#use-mcp-or-a-framework-adapter]

[Framework adapters](/docs/agents/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](/docs/agents/reference/tools#partner-decisions).

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 [#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`:

```json
{
  "toolPattern": "refund.create",
  "effect": "allow",
  "conditions": [{ "parameter": "amount", "operator": "lt", "value": 500 }]
}
```

```json
{
  "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 [#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 [#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:

```ts
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 [#run-it-once-and-prove-it-ran]

For execution receipts and a single-use permit, use `protect()`:

```ts
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.reason
```

The 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 [#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-and-operate]

[Test your integration](/docs/agents/embed-testing) walks through approval, denial, unanswered decisions, retries and real delivery. [Pricing and limits](/docs/pricing-and-limits#partner-plan-embed) covers the accepted-request and legacy delivery allowances, API limits and decision lifetime.

## Next steps [#next-steps]

<Cards>
  <Card title="Test your integration" description="Sandbox keys, retries and live delivery checks before you turn on real actions." href="/docs/agents/embed-testing" />

  <Card title="Use a framework adapter" description="Vercel AI SDK, LangGraph, OpenAI Agents SDK, Mastra, Claude Agent SDK and more." href="/docs/agents/adapters" />
</Cards>