> This page is for version 🇦🇺 AU (default).
> For other versions, use one of these documentation indexes:
> - 🇦🇺 AU (default): https://docs.humanapproved.com/au/llms.txt
> - 🇬🇧 UK: https://docs.humanapproved.com/uk/llms.txt
> - 🇺🇸 US: https://docs.humanapproved.com/us/llms.txt

> This is HumanApproved's documentation, as Markdown for AI agents. /llms.txt lists every page; /llms-full.txt has them all. If you're helping someone integrate HumanApproved, start with /guides/ai-coding-agents.

# Server and agent flows

> How to integrate HumanApproved without the embed, from a server or an AI agent flow such as an MCP tool that binds a policy.

Use the API directly when there's no browser for the embed, such as a check from your server or an MCP tool that binds a policy. For a website, use [the embed](/guides/add-the-embed), which handles all of this.

Creating the review from your server is also the most secure setup: the quote goes from your server to HumanApproved, so it never passes through the agent.

## Before you start

Set your keys and your region's address as environment variables. Use test keys while you build.

```bash
export HUMANAPPROVED_URL="https://au.humanapproved.com"  # your region's address
export HUMANAPPROVED_PUBLISHABLE_KEY="pk_test_…"         # one product
export HUMANAPPROVED_SECRET_KEY="sk_test_…"              # servers only
```

Every call except `/api/v1/server/*` needs an `Origin` header that is one of the product's allowed websites for the key's environment. Browsers send it themselves. From a server, send it yourself, for example `Origin: https://www.example.com`.

## Make sure the check asks for approval

From a server there's no browser to observe, so HumanApproved only sees your server's own request. Decide on the product instead:

* **Every purchase through the flow is an agent's** (for example an MCP tool): set the product's **When to ask for approval** to **Every purchase** and publish. Every check returns `review`.
* **You know which purchases are an agent's**: set the product to use your own signal on its **Agent detection** page, and send `"signal": { "source": "mcp", "isAgent": true }` with the check. It returns `review` unless the product is set to never ask ([monitor only](/guides/monitor-only)).

## The lifecycle

#### Create a check

Send your quote reference with the publishable key.

**`curl`**

```bash title="curl"
curl -X POST $HUMANAPPROVED_URL/api/v1/checks \
  -H "Authorization: Bearer $HUMANAPPROVED_PUBLISHABLE_KEY" \
  -H "Origin: https://www.example.com" \
  -H "Content-Type: application/json" \
  -d '{ "quoteRef": "HTA-4471920", "userId": "CUST-1001" }'
```

**`Node`**

```js title="Node"
const res = await fetch(`${process.env.HUMANAPPROVED_URL}/api/v1/checks`, {
  method: "POST",
  headers: {
    authorization: `Bearer ${process.env.HUMANAPPROVED_PUBLISHABLE_KEY}`,
    origin: "https://www.example.com",
    "content-type": "application/json",
  },
  body: JSON.stringify({ quoteRef: "HTA-4471920", userId: "CUST-1001" }),
});
const check = await res.json();
```

The response is `continue` or `review`:

**`continue`**

```json title="continue"
{ "checkId": "chk_…", "outcome": "continue" }
```

**`review`**

```json title="review"
{
  "checkId": "chk_…",
  "outcome": "review",
  "review": {
    "id": "rv_…",
    "token": "…",
    "url": "https://au.humanapproved.com/r/rv_…",
    "expiresAt": "2026-11-19T09:14:23.000Z",
    "brand": { "name": "Acme Travel" },
    "agentPanel": { "text": { "relay": "…", "never": "…" } }
  }
}
```

On `continue`, go ahead and [report the result](#report-the-result). On `review`, keep the review's `id` and `token` on your server against the quote reference. The token is shown once, and only it can send the quote and read the status.

#### Send the quote

Send the quote and the customer's mobile with the review's token, once, while the review is waiting for it. The quote must match the product's published fields; fields it doesn't define are dropped.

**`curl`**

```bash title="curl"
curl -X PUT $HUMANAPPROVED_URL/api/v1/reviews/$REVIEW_ID/quote \
  -H "Authorization: Bearer $REVIEW_TOKEN" \
  -H "Origin: https://www.example.com" \
  -H "Content-Type: application/json" \
  -d '{
    "quote": {
      "policyholder": { "firstName": "Sam" },
      "destination": "Japan",
      "dates": { "from": "2026-12-03", "to": "2026-12-17" },
      "travellers": 2,
      "cover": "Comprehensive",
      "excess": 200,
      "premium": { "amount": 412.6, "currency": "AUD" }
    },
    "mobile": "+61 491 570 156"
  }'
```

**`Node`**

```js title="Node"
const res = await fetch(
  `${process.env.HUMANAPPROVED_URL}/api/v1/reviews/${process.env.REVIEW_ID}/quote`,
  {
    method: "PUT",
    headers: {
      authorization: `Bearer ${process.env.REVIEW_TOKEN}`,
      origin: "https://www.example.com",
      "content-type": "application/json",
    },
    body: JSON.stringify({
      quote: {
        policyholder: { firstName: "Sam" },
        destination: "Japan",
        dates: { from: "2026-12-03", to: "2026-12-17" },
        travellers: 2,
        cover: "Comprehensive",
        excess: 200,
        premium: { amount: 412.6, currency: "AUD" },
      },
      mobile: "+61 491 570 156",
    }),
  },
);
const sent = await res.json(); // { status: "pending", url, expiresAt }
```

Then show the customer, or the agent, the approval link (`url`). A missing or wrongly typed field returns `422 invalid_quote`, with `fields` listing each one. A second send returns `409 quote_already_set`: treat it as sent.

#### Poll the status

**`curl`**

```bash title="curl"
curl $HUMANAPPROVED_URL/api/v1/reviews/$REVIEW_ID/status \
  -H "Authorization: Bearer $REVIEW_TOKEN" \
  -H "Origin: https://www.example.com"
```

**`Node`**

```js title="Node"
const res = await fetch(
  `${process.env.HUMANAPPROVED_URL}/api/v1/reviews/${process.env.REVIEW_ID}/status`,
  {
    headers: {
      authorization: `Bearer ${process.env.REVIEW_TOKEN}`,
      origin: "https://www.example.com",
    },
  },
);
const { status, declineReason } = await res.json();
```

| `status`         | Means                                                                                       |
| ---------------- | ------------------------------------------------------------------------------------------- |
| `awaiting_quote` | The quote hasn't been sent yet.                                                             |
| `pending`        | Waiting for the customer.                                                                   |
| `approved`       | The customer approved. Go ahead.                                                            |
| `declined`       | The customer declined. Don't go ahead. `declineReason` gives the reason, if they chose one. |
| `expired`        | The link expired. Start again with a new check.                                             |

Poll every 3 seconds, then every 10 seconds after 10 minutes. After an error, slow down to at most once every 60 seconds. Stop at `approved`, `declined`, `expired` or the review's `expiresAt`. Before you bind, [verify the review on your server](/guides/verify-on-your-server) with your secret key.

#### Report the result

Tell HumanApproved how the check ended, with the publishable key.

**`curl`**

```bash title="curl"
curl -X POST $HUMANAPPROVED_URL/api/v1/checks/$CHECK_ID/result \
  -H "Authorization: Bearer $HUMANAPPROVED_PUBLISHABLE_KEY" \
  -H "Origin: https://www.example.com" \
  -H "Content-Type: application/json" \
  -d '{ "result": "completed" }'
```

**`Node`**

```js title="Node"
await fetch(`${process.env.HUMANAPPROVED_URL}/api/v1/checks/${process.env.CHECK_ID}/result`, {
  method: "POST",
  headers: {
    authorization: `Bearer ${process.env.HUMANAPPROVED_PUBLISHABLE_KEY}`,
    origin: "https://www.example.com",
    "content-type": "application/json",
  },
  body: JSON.stringify({ result: "completed" }),
});
```

| `result`    | Send it when                                                       |
| ----------- | ------------------------------------------------------------------ |
| `completed` | You proceeded after `continue`, or after the customer approved.    |
| `fail_open` | You proceeded without a response, for example the check timed out. |
| `abandoned` | You didn't go ahead: declined, expired, or the customer gave up.   |

The response is `204`. Only the first result for a check is kept, so retrying is safe.

## Pass the link to an agent, and resume

An agent can't approve: only the customer can. The agent passes the link to the customer and checks back later. Give it the review's `url` with instructions, keep the review's ID and token on your server against the quote reference, and check the status when the agent calls again.

> **Warning**
>
> Never give the agent the review token, or any of your keys. The token is the review's credential: keep it on your server. Return only the approval link and instructions.

This example `bind_policy` tool for an agent, for example in an MCP server, runs on your server:

**`bind-policy.js`**

```js title="bind-policy.js"
// `call` sends a request as in The lifecycle, and `PK` is your publishable key.
// `reviews`, `bind` and `quoteDetails` are your own.
export async function bindPolicy({ quoteRef }) {
  const saved = await reviews.get(quoteRef);
  if (saved) {
    const { status } = await call(`/api/v1/reviews/${saved.id}/status`, saved.token);
    if (status === "approved") return { status: "bound", policy: await bind(quoteRef) };
    if (status === "pending") return askCustomer(saved.url, saved.panel);
    await reviews.delete(quoteRef); // declined or expired: start again
  }

  const check = await call("/api/v1/checks", PK, "POST", { quoteRef });
  if (check.outcome === "continue") return { status: "bound", policy: await bind(quoteRef) };

  const { id, token, agentPanel } = check.review;
  const { url } = await call(`/api/v1/reviews/${id}/quote`, token, "PUT", await quoteDetails(quoteRef));
  await reviews.set(quoteRef, { id, token, url, panel: agentPanel });
  return askCustomer(url, agentPanel);
}

// What the agent gets: the link and instructions, never the token.
const askCustomer = (url, panel) => ({
  status: "approval_needed",
  approvalUrl: url,
  instructions: [panel.text.relay, panel.text.instructions, panel.text.never, url].filter(Boolean).join(" "),
});
```

`reviews`, `bind` and `quoteDetails` are your own: a store for the review, your bind, and the quote and mobile for that reference from your own records. Report each check's result, and treat a `401` or `403` on the status call as expired, as described in [The lifecycle](#the-lifecycle).

### Use the product's own words

The check's `review.agentPanel.text` holds every piece of wording the embed's pause panel shows: your product's wording from its **Agent popup** page, or the standard wording. Use it so an agent gets the same wording whichever way it buys:

* `relay` asks the agent to send the link to the customer.
* `instructions` holds your own extra instructions, empty when there are none.
* `never` tells the agent not to open or approve the link itself.

The text is plain, with no markup or links. Some pieces contain placeholders, such as `{expiry}`, `{reason}` and `{brand}`, for you to fill in. Whatever you show, make sure the agent knows not to open or approve the link itself. See [The pause panel](/embed/sdk/pause-panel).

## Related

* [Verify on your server](/guides/verify-on-your-server): read the outcome with your secret key before you bind.