> ## Documentation Index
> Fetch the complete documentation index at: https://docs.phoneuse.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Be told when a run ends, when it needs a person, when a schedule fires, and when an order changes.

Polling a run to find out when it finished works, and it is the wrong shape for
anything that runs unattended. A webhook subscription turns those four moments
into requests against an endpoint you own.

<Info>
  **What it costs.** Nothing. Subscriptions are not metered, and a delivery is not
  a step.

  **What you need.** An API key with `devices:read`, and an **https endpoint
  reachable from the public internet**. Your laptop behind a private address will
  be refused at subscribe time, not at delivery time; use a tunnel or a deployed
  endpoint.

  **There is no console screen for this.** Webhooks are API only.
</Info>

## Subscribe

```bash theme={null}
curl -sS -X POST "$BASE/v1/webhooks" \
  -H "Authorization: Bearer $PHONEBASE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks/phonebase","events":["run.ended"]}'
```

`201`, and the response carries the signing secret **once**:

```json theme={null}
{
  "webhook": {
    "webhookId": "...",
    "url": "https://example.com/hooks/phonebase",
    "events": ["run.ended"],
    "enabled": true,
    "secretHint": "...",
    "createdAt": "...",
    "secretRotatedAt": null
  },
  "secret": "whsec_..."
}
```

Store the secret now. To get another you have to rotate, and rotating stops the
old one working. `secretHint` is its last four characters, which is there so you
can tell which secret you configured without holding the secret to compare.

An org may hold up to **20** subscriptions.

### What the destination must be

The url is checked when you subscribe, and a refusal names which rule you hit:
`not_a_url`, `scheme_not_https`, `has_credentials`, `private_address`,
`too_long`. It must be `https`, must carry no credentials in it, must be at most
2000 characters, and must not be a private, loopback or link local address.
That last one is not a style preference: this service would be the one making
the request, so an address inside a private network is an instruction to probe
somebody's network from ours.

### The four events

| Event | When | `data` carries |
| - | - | - |
| `run.ended` | A run reached a terminal state, however it got there | `agentRunId`, `runId`, `deviceId`, `state`, `goal`, `stepSeq`, `reason`, `detail` |
| `run.needs_user_control` | A worker parked a run because it needs a person | `agentRunId`, `deviceId`, `goal`, `deadlineAt` |
| `schedule.triggered` | A schedule fired and got a device | `scheduleId`, `name`, `agentRunId`, `scheduledFor` |
| `fulfilment.changed` | An order became `provisioned` or `awaiting_provisioning`, or ended as `cancelled` with `deviceId` `null`. `cancelled` is sent when the payment provider ends the subscription or refunds or disputes it, and when the customer presses Return or Cancel. It is not sent for an owner refund, the undelivered-order sweep, a day pass expiring or a Cloud undelivered cancel; reconcile those with `GET /v1/fulfilments` | `fulfilmentId`, `state`, `deviceId` |

An unknown event name is **refused** rather than ignored, so a typo fails at
subscribe time instead of producing a subscription that never fires.

`reason` and `detail` on `run.ended` are present and `null` rather than absent
when there is nothing to say. A receiver branching on why a run ended needs to
tell "no reason" apart from "not told".

## The body

```json theme={null}
{
  "deliveryId": "...",
  "event": "run.ended",
  "occurredAt": "2026-09-13T10:24:12.004Z",
  "data": { "agentRunId": "run_...", "state": "succeeded", "...": "..." }
}
```

`occurredAt` is when the **event** happened, not when this attempt was made.

`data` is deliberately shallow and carries ids rather than nested records. A
receiver that needs the run reads `GET /v1/runs/{agentRunId}`, which is the one
definition of what a run is. Embedding a copy here would publish a second one
that drifts.

Three headers come with it:

| Header | What it is |
| - | - |
| `Phonebase-Event` | The event name, so a router does not have to parse the body first |
| `Phonebase-Delivery` | The delivery id, **the same on every retry**. Deduplicate on it |
| `Phonebase-Signature` | `t=<unix seconds>,v1=<hex>` |

## Verify the signature

The hex is **HMAC-SHA256** of `<t>.<raw body>` under your secret.

```js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=", 2))
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1 ?? "", "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Three things that are easy to get wrong:

1. **Compute over the raw bytes you received**, before any JSON parsing. A body
   that has been through `JSON.parse` and back is not the body that was signed.
2. **Compare in constant time.** A byte at a time comparison leaks the answer.
3. **Check `t` against your own clock.** The timestamp is inside the signed
   material precisely so that a captured delivery cannot be replayed at you a
   year later. The reference verifier uses a 300 second tolerance.

## Delivery is at least once

Plan for a duplicate. Deduplicate on `Phonebase-Delivery`.

* A non 2xx answer is retried, **5 attempts in all**, with gaps of 30 seconds, 2
  minutes, 5 minutes and 18 minutes, so about twenty five minutes from first to
  last. Then it is given up on.
* Each attempt gets **10 seconds** before it times out.
* **Redirects are not followed.** A `30x` is recorded as the answer it is,
  because following one would send your payload to a url nobody checked.
* Answer `2xx` fast and do the work afterwards. An endpoint that does its
  processing before replying will be retried while it is still working.

## Test it before you trust it

```bash theme={null}
curl -sS -X POST "$BASE/v1/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer $PHONEBASE_API_KEY"
```

`202` with `{ "deliveryId": "...", "status": "pending" }`. It queues one
delivery on the **same path a real event takes**: same signature, same headers,
same retries. It answers as soon as the delivery is queued rather than waiting
for your endpoint, so read the outcome from the deliveries listing.

It arrives as `run.ended` with `test: true` in the `data`, because a test that
came in as something your router does not recognise would exercise the transport
and nothing else.

## Read what happened

```bash theme={null}
curl -sS "$BASE/v1/webhooks/$WEBHOOK_ID/deliveries?limit=20" \
  -H "Authorization: Bearer $PHONEBASE_API_KEY"
```

Newest first, `limit` between 1 and 100 and 50 by default, paged with the
`nextBefore` cursor the response hands back.

Each row says what was tried and what came of it: `status`, `attempts`,
`responseStatus`, `error`, `payloadPreview` (the delivered body, truncated),
`lastAttemptAt` and `nextAttemptAt`.

Read `status` carefully. `pending` covers both never tried and waiting to retry.
`failed` means **every attempt is spent**, not that your endpoint said no.
`responseStatus` is `null` when the attempt never got one, and `error` then says
why: a timeout, a refused connection, a name that did not resolve.

<Note>
  `payloadPreview` is the fastest way to see the exact shape of an event you
  have not handled before. Subscribe, fire one, read the preview.
</Note>

## Turning one off, and rotating its secret

| | Route | What it does |
| - | - | - |
| Disable | `PATCH /v1/webhooks/{webhookId}` with `{"enabled": false}` | Receives nothing. Deliveries already queued for it are **not** retried either |
| Rotate | `POST /v1/webhooks/{webhookId}/rotate` | New secret returned once, and the old one stops working **immediately** |
| Delete | `DELETE /v1/webhooks/{webhookId}` | Removes it and its delivery records with it |

<Warning>
  Rotation has **no overlap window**. A receiver still verifying with the old
  secret will reject every delivery from that moment. Put the new secret in
  place first, or accept a gap.
</Warning>

Disable rather than delete when you want to keep the history: a delivery log for
a subscription nobody can see is a log nobody can interpret, so deleting takes
it too.

## Scope and tenancy

Every webhook route needs **`devices:read`** and nothing else. There is no role
gate: a member with that scope can manage subscriptions.

A `webhookId` belonging to another org answers `404`, the same `404` an id that
names nothing gets. This is not an existence oracle.

## Where to go next

<Columns cols={2}>
  <Card title="Runs" href="/agent/runs" icon="list-check">
    What `state`, `reason` and `detail` on `run.ended` mean.
  </Card>
</Columns>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.