---
title: Handle approvals
description: Three integration patterns for waiting on a human, and how to resume work that was suspended for twenty minutes.
---

When policy returns `REQUIRE_APPROVAL`, the action stops. Your code has to do
something sensible with a request that may be answered in ten seconds or twenty
minutes, or not at all.

Three patterns. The right one depends on whether you have a process that can
afford to wait.

## Pattern 1 — block and wait

For a background worker or a queue consumer, where blocking costs nothing.

:::tabs

:::tab TypeScript
```ts
const decision = await agent.veefore.actWithApproval(
  {
    tool: 'refund_customer',
    capability: 'refund.create',
    amount: 250_00,
    currency: 'INR',
    parameters: { orderId: 'ord_8812' },
    purpose: 'Customer reported damage; agent verified photo evidence.',
  },
  { timeoutMs: 15 * 60 * 1000 },
);
```
:::

:::tab Python
```python
decision = agent.veefore.act_with_approval(
    ActionRequest(
        tool="refund_customer",
        capability="refund.create",
        amount=250_00,
        currency="INR",
        parameters={"orderId": "ord_8812"},
        purpose="Customer reported damage; agent verified photo evidence.",
    ),
    timeout_seconds=900,
)
```
:::

:::

It evaluates, waits on the approval honouring `pollAfterMs`, then resubmits the
identical request with the `approvalId`. On denial it raises
`VeeforeApprovalDeniedError`; on timeout, a connection error.

## Pattern 2 — return control, resume later

For a request-response path, where holding a connection for twenty minutes is
not acceptable.

:::tabs

:::tab TypeScript
```ts
import { VeeforeApprovalRequiredError } from '@veefore/sdk';

async function startRefund(orderId: string) {
  try {
    const decision = await agent.act({
      tool: 'refund_customer',
      capability: 'refund.create',
      amount: 250_00,
      currency: 'INR',
      parameters: { orderId },
      // Stable across the first attempt and the resume, so the approved retry
      // is recognised as the same operation rather than a second refund.
      idempotencyKey: `refund-${orderId}`,
    });
    return { status: 'done', result: decision.result };
  } catch (error) {
    if (error instanceof VeeforeApprovalRequiredError) {
      await jobs.enqueue('resume-refund', {
        orderId,
        approvalId: error.approvalId,
      });
      return { status: 'awaiting_approval', approvalId: error.approvalId };
    }
    throw error;
  }
}

async function resumeRefund(job: { orderId: string; approvalId: string }) {
  const status = await agent.veefore.approvalStatus(job.approvalId);
  if (status.status === 'PENDING') return 'retry_later';
  if (status.status !== 'APPROVED') return `abandoned:${status.status}`;

  const decision = await agent.act({
    tool: 'refund_customer',
    capability: 'refund.create',
    amount: 250_00,
    currency: 'INR',
    parameters: { orderId: job.orderId },
    idempotencyKey: `refund-${job.orderId}`,
    approvalId: job.approvalId,
  });
  return decision.allowed ? 'done' : 'refused';
}
```
:::

:::tab Python
```python
from veefore import ActionRequest, VeeforeApprovalRequiredError


def start_refund(order_id: str):
    request = ActionRequest(
        tool="refund_customer",
        capability="refund.create",
        amount=250_00,
        currency="INR",
        parameters={"orderId": order_id},
        idempotency_key=f"refund-{order_id}",
    )
    try:
        decision = agent.act(request)
        return {"status": "done", "result": decision.result}
    except VeeforeApprovalRequiredError as error:
        jobs.enqueue("resume-refund", order_id=order_id, approval_id=error.approval_id)
        return {"status": "awaiting_approval", "approval_id": error.approval_id}


def resume_refund(order_id: str, approval_id: str) -> str:
    status = agent.veefore.approval_status(approval_id)
    if status.status == "PENDING":
        return "retry_later"
    if status.status != "APPROVED":
        return f"abandoned:{status.status}"

    decision = agent.act(
        ActionRequest(
            tool="refund_customer",
            capability="refund.create",
            amount=250_00,
            currency="INR",
            parameters={"orderId": order_id},
            idempotency_key=f"refund-{order_id}",
            approval_id=approval_id,
        )
    )
    return "done" if decision.allowed else "refused"
```
:::

:::

:::warning Resubmit the identical request
The approval is bound to the exact request that created it — the agent, the
tool, the parameters, the amount. Change any of it and the gateway refuses with
`APPROVAL_NOT_GRANTED`.

That is the point: it stops "approved once" from becoming "approved for whatever
I send next". Persist the request alongside the `approvalId` rather than
rebuilding it from scratch and hoping it comes out the same.
:::

## Pattern 3 — decide as data

When you want the decision without exception control flow.

```ts
const decision = await agent.evaluate(request);

if (decision.allowed) return execute(decision);

if (decision.approval?.required) {
  return {
    status: 'awaiting_approval',
    approvalId: decision.approval.approvalId,
    expiresAt: decision.approval.expiresAt,
    pollAfterMs: decision.approval.pollAfterMs,
  };
}

return { status: 'refused', reason: decision.reason };
```

`evaluate()` throws only on transport and authentication failures, so a `BLOCK`
and a `REQUIRE_APPROVAL` both come back as values.

## Respect `pollAfterMs`

The response tells you when to poll next. Honour it.

Polling faster will not make a human decide faster, and it will earn you
`RATE_LIMITED`. `waitForApproval` already uses it as its interval.

## Handle expiry

An approval has a TTL — default 900 seconds, set per rule. When it passes the
status becomes `EXPIRED` and the action is refused with `APPROVAL_EXPIRED`.

Your resume path must handle all four terminal states:

| Status | Meaning | Do |
| --- | --- | --- |
| `APPROVED` | Proceed | Resubmit with `approvalId` |
| `DENIED` | A human said no | Report the reason. Do not retry |
| `EXPIRED` | Nobody answered in time | Escalate to a person, or abandon |
| `CANCELLED` | Withdrawn | Abandon |

`EXPIRED` is the one that gets missed, and it is the most likely in practice.

## Make the approval card useful

The approver sees the request, the policy and rule that required approval, the
risk signals, the delegation chain, the agent's recent history — and `purpose`.

```ts
purpose: 'Customer reported damage on arrival; agent verified photo evidence and the order is within the 30-day window.'
```

An approver without context approves everything, which is worse than having no
approval step — because now there is a record saying a human reviewed it. Spend
a sentence here.

## Test it before you need it

Walk the whole loop at least once in `SIMULATE`, with a real person:

1. The agent requests and gets `APPROVAL_REQUIRED`.
2. The approval appears in the console queue with full context.
3. Someone approves it.
4. The agent resumes and proceeds.

Then deny one, and check the agent reports the denial reason usefully. Then let
one expire, and check the resume path does not loop forever.

:::warning Confirm someone owns the queue
`ENFORCE` with approval rules and an unwatched queue is just a slower kind of
blocked. Decide who owns it, what the response-time expectation is, and what
happens out of hours — before you promote.
:::

## Related

:::cards

:::card Approvals | /concepts/approvals
Routing, quorum, expiry and the binding guarantee.
:::

:::card Approvals queue | /operate/approvals-queue
The operator side.
:::

:::card Idempotency | /build/idempotency
Why the key matters across a resume.
:::

:::card Approvals API | /reference/api/approvals
List, read, approve, deny.
:::

:::
