---
title: Webhooks
description: Get your sandboxes' events posted to your own HTTPS endpoint, and verify their signature.
---

pols.so can post your sandboxes' events to HTTPS URLs of yours as JSON, so you do not have to poll for a sandbox to become ready or to stop.

## Adding an endpoint

Owners and admins of your org add and delete endpoints on your [account page](https://my.pols.so/account). An API key cannot: it can only list the endpoints (`GET /v1/webhooks`) and their deliveries (`GET /v1/webhooks/{webhook}/deliveries`); see the [API reference](/api/webhooks/list-webhooks/).

For each endpoint you give:

- an `https://` URL of at most 2,048 characters, on a public host, without a user name, password or fragment
- the events it gets
- optionally a description of up to 200 characters

When you add it, the page shows its signing secret (`whsec_...`) once. Copy it then: it is not shown again. An org may have up to 10 endpoints.

## Events

| Event | When |
| --- | --- |
| `sandbox.ready` | the sandbox's status became `running` after a create, resume or fork, so exec and file calls work |
| `sandbox.stopped` | its status became `stopped`: after a stop, when the org's monthly sandbox-hours or credit ran out or a trial org held too much disk, or because its VM stopped by itself (then `last_error` says so) |
| `sandbox.error` | its status became `error` |
| `sandbox.expired` | its `expires_at` passed and it is being deleted |

Going to standby and waking from it raise no event.

## Deliveries

Each delivery is a `POST` with a JSON body:

```json
{
  "id": "evt_8f2k1m9x0q3z",
  "type": "sandbox.ready",
  "created_at": "2026-10-04T12:00:00Z",
  "org_id": "org_...",
  "data": {
    "sandbox": {
      "id": "sbx_...",
      "name": "web",
      "status": "running",
      "desired_state": "running"
    }
  }
}
```

The sandbox also carries `last_error` and `expires_at` when it has them. The request has the headers `Pols-Signature`, `Pols-Event-Id`, `Pols-Event-Type`, `Pols-Delivery-Id` and `Pols-Delivery-Attempt` (1 for the first attempt).

A delivery succeeds when your endpoint answers with a 2xx status within 10 seconds. Redirects are not followed. A failed delivery is retried with growing waits (30 seconds, then 2, 10 and 30 minutes, then 1, 3, 6 and 12 hours): nine attempts over about a day, after which it is marked failed. Every attempt has the same body and a fresh signature timestamp, so the same event can arrive more than once, and events can arrive out of order.

Events and their deliveries are kept for 30 days.

## Verifying the signature

The `Pols-Signature` header looks like `t=1791119032,v1=5f1c...`: `t` is the Unix time of the attempt, `v1` the hex HMAC-SHA256, keyed with the endpoint's secret, of `t`, a `.` and the raw body. Before you trust a delivery:

1. Compute the HMAC over the raw body as received, before parsing it, and compare it with `v1` in constant time.
2. Refuse timestamps more than 5 minutes from your clock.
3. Handle each event `id` once.

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

function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const want = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
  const got = Buffer.from(parts.v1 ?? "", "hex");
  return got.length === want.length / 2 && timingSafeEqual(got, Buffer.from(want, "hex"));
}
```
