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

# Webhooks

> Get your simulated orders, fills, settlements and wallet changes POSTed to your own HTTPS endpoint, signed with HMAC-SHA256.

# Webhooks

A webhook endpoint is a URL on your server. When something happens on your
PolySimulator account (an order is placed, fills or is cancelled, a position
settles, a wallet balance changes), PolySimulator sends a signed `POST` to that
URL. All of these events come from simulated trading: PolySimulator never places
orders on Polymarket or on any other venue.

> [!NOTE]
>
> Developer webhooks ship with the next PolySimulator release. Without a key,
> `GET /v1/developer/webhooks/event-types` returns **401** on an API host that
> has them. A **404** means that host does not serve them yet.

***

## Before you start

| Requirement | Details |
| - | - |
| Authentication | `X-API-Key: <your key>`, or `Authorization: Bearer <access token>` from a signed-in session. |
| Plan | Creating or changing an endpoint, sending a test event and rotating a secret need a **Pro** or **Pro+** plan. Without one these calls return `402` with `{"error": "UPGRADE_REQUIRED", "feature_key": "alerts.webhook", ...}`. Listing, reading and deleting endpoints, reading the delivery log and replaying a delivery work on any plan. |
| Endpoint limit | Up to 10 endpoints per account. The 11th returns `400 ENDPOINT_LIMIT`. |
| Your URL | `https://` only. The hostname must resolve to public IP addresses only. See [URL rules](#url-rules). |

You can also manage endpoints in the developer console: **API Keys → Webhooks**.

***

## Endpoints

All paths are under `https://api.polysimulator.com`.

| Method | Path | Needs Pro | What it does |
| - | - | - | - |
| `GET` | `/v1/developer/webhooks/event-types` | No | Lists the event types you can subscribe to. |
| `POST` | `/v1/developer/webhooks` | Yes | Creates an endpoint. Returns the signing secret **once**. `201`. |
| `GET` | `/v1/developer/webhooks` | No | Lists your endpoints: `{"endpoints": [...]}`. |
| `GET` | `/v1/developer/webhooks/{endpoint_id}` | No | Reads one endpoint. |
| `PATCH` | `/v1/developer/webhooks/{endpoint_id}` | Yes | Changes `url`, `description`, `event_types` or `status` (`active` / `paused`). |
| `DELETE` | `/v1/developer/webhooks/{endpoint_id}` | No | Deletes the endpoint and its delivery log. `204`. |
| `POST` | `/v1/developer/webhooks/{endpoint_id}/rotate-secret` | Yes | Replaces the signing secret. Returns the new secret **once**. |
| `POST` | `/v1/developer/webhooks/{endpoint_id}/test` | Yes | Sends a signed `webhook.test` event now and returns the result. |
| `GET` | `/v1/developer/webhooks/{endpoint_id}/deliveries` | No | Delivery log, newest first. |
| `POST` | `/v1/developer/webhooks/deliveries/{delivery_id}/replay` | No | Queues a failed delivery again. See [Replay](#replay-a-failed-delivery). |

An endpoint that is not yours returns `404 NOT_FOUND`, the same as one that
does not exist.

***

## Create an endpoint

```bash theme={null}
curl -X POST https://api.polysimulator.com/v1/developer/webhooks \
  -H "X-API-Key: $POLYSIM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/polysim/webhook",
    "description": "fills to my bot",
    "event_types": ["order.filled", "position.settled"]
  }'
```

| Field | Type | Default | Rules |
| - | - | - | - |
| `url` | string | required | 8 to 2048 characters; see [URL rules](#url-rules). |
| `description` | string | `null` | Up to 255 characters. |
| `event_types` | string\[] | `["*"]` | Event types from the table below, or `"*"` for all of them. A list that contains `"*"` is stored as `["*"]`. An empty list or an unknown type is a `422`. |

Response (`201`):

```json theme={null}
{
  "id": 17,
  "url": "https://example.com/polysim/webhook",
  "description": "fills to my bot",
  "event_types": ["order.filled", "position.settled"],
  "status": "active",
  "secret_hint": "9f3a",
  "created_at": "2026-10-21T09:12:04.551Z",
  "updated_at": "2026-10-21T09:12:04.551Z",
  "secret": "<64 hex characters>"
}
```

> [!WARNING]
>
> `secret` appears only in this response and in the response of
> `rotate-secret`. Store it now. Later reads return `"secret": null` and only
> `secret_hint`, the last 4 characters.

A new endpoint receives only events that happen **after** it was created. Past
events are not sent.

### URL rules

PolySimulator checks the URL when you create or change an endpoint, and again
before every delivery:

1. The scheme must be `https://`.
2. `localhost` and hostnames ending in `.internal` or `.local` are refused.
3. **Every** address the hostname resolves to must be a public address.
   Private, loopback, link-local, carrier-grade NAT (`100.64.0.0/10`),
   reserved, multicast and IPv4-mapped IPv6 addresses are refused.
4. Redirects are not followed. A `3xx` answer counts as a failed attempt.

A refused URL returns `400 INVALID_URL` with the reason in `message`.

***

## Event types

`GET /v1/developer/webhooks/event-types` returns the current list, with these descriptions:

| Event type | Description |
| - | - |
| `order.placed` | A limit order was accepted and is resting. |
| `order.filled` | An order filled; includes the order, position and wallet patch. |
| `order.cancelled` | A resting order was cancelled and its reserve refunded. |
| `position.settled` | A position closed because its market resolved. |
| `position.emergency_exit` | A position was closed by an emergency exit. |
| `wallet.changed` | A wallet balance changed. |
| `market.created` | A new market was created or accepted. |
| `trader.new_trade` | A followed trader executed a trade. |

Two notes:

1. `market.created` is not about your account: it is sent for every new
   market, to every endpoint subscribed to it, including `"*"`.
2. `trader.new_trade` is sent only for traders you follow whose profile is
   public and shows their trades and positions.

The test call sends one more type, `webhook.test`, which you cannot subscribe to.

***

## What a delivery looks like

Each delivery is an HTTP `POST` with a JSON body.

### Headers

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `PolySimulator-Webhook/1.0` |
| `X-PolySim-Event` | The event type, e.g. `order.filled` |
| `X-PolySim-Delivery-Id` | Same as `delivery_id` in the body |
| `X-PolySim-Timestamp` | Unix time in seconds when this attempt was signed |
| `X-PolySim-Signature-V2` | `sha256=<hex>`: HMAC-SHA256 of `<timestamp>.<raw body>` |
| `X-PolySim-Signature` | `sha256=<hex>`: HMAC-SHA256 of the raw body (older scheme, no timestamp) |

### Body

```json theme={null}
{
  "event_id": "evt_918273",
  "delivery_id": "dlv_55120",
  "event_type": "order.filled",
  "user_id": "usr_4242",
  "ts": "2026-10-21T09:15:31.204118+00:00",
  "payload": {
    "version": 1,
    "wallet": {"id": 311, "balance": "9871.20"},
    "order": {
      "id": 685412,
      "market_id": "0x1a2b3c...",
      "side": "BUY",
      "outcome": "Yes",
      "status": "FILLED",
      "price": "0.42",
      "quantity": "100",
      "fee": "0.84",
      "wallet_id": 311
    },
    "position": {
      "id": 90211,
      "market_id": "0x1a2b3c...",
      "outcome": "Yes",
      "status": "OPEN",
      "quantity": "100",
      "avg_entry_price": "0.42",
      "exit_price": null,
      "wallet_id": 311
    }
  }
}
```

| Field | Meaning |
| - | - |
| `event_id` | The event, `evt_` + a number. The same event has the same `event_id` in every attempt and on every endpoint it is sent to. |
| `delivery_id` | This event on this endpoint, `dlv_` + a number. It stays the same across retries and replays of that delivery. |
| `event_type` | One of the types above. |
| `user_id` | The account the event belongs to, `usr_` + a number. |
| `ts` | When the event happened (ISO 8601, UTC). |
| `payload` | Depends on the type; see below. Prices, quantities, fees and balances are strings; ids are integers. If the body would exceed 256 KB, `payload` is replaced by `{"_truncated": true}`. |

### Payload by event type

Every payload has `"version": 1`.

| Event type | Payload fields |
| - | - |
| `order.placed`, `order.cancelled` | `wallet` (`id`, `balance`), `order` (`id`, `market_id`, `side`, `outcome`, `status`, `price`, `quantity`, `wallet_id`, `cancelled_reason`), `position: null` |
| `order.filled` | `wallet`, `order` (as above, with `fee` instead of `cancelled_reason`), `position` (`id`, `market_id`, `outcome`, `status`, `quantity`, `avg_entry_price`, `exit_price`, `wallet_id`) |
| `position.settled`, `position.emergency_exit` | `wallet`, `order: null`, `position` |
| `wallet.changed` | `wallet`, `order: null`, `position: null` |
| `market.created` | `condition_id`, `market_id` (the same value), `question` |
| `trader.new_trade` | `trader_id`, `trader_username` (when the trader has one), `order_id`, `market_id`, `order` (`id`, `market_id`, `side`, `outcome`, `status`, `price`, `quantity`). No wallet or position data of the trader. |
| `webhook.test` | `message`. `event_id` is `evt_0` and `delivery_id` starts with `dlv_test_`. |

`wallet` can be `null` when the event has no wallet. `balance` is the balance
after the change.

***

## Verify the signature

Verify every request before you act on it:

1. Read the **raw** request body as bytes. Do not parse and re-serialize the
   JSON first: the signature covers the exact bytes sent.
2. Reject the request if `X-PolySim-Timestamp` is more than 5 minutes away from
   your clock. This stops an old request from being replayed.
3. Compute HMAC-SHA256 over `<timestamp>` + `.` + `<raw body>`. The key is your
   secret **as text** (its UTF-8 bytes), not the hex-decoded bytes.
4. Compare `"sha256=" + <hex digest>` with `X-PolySim-Signature-V2` using a
   constant-time comparison.

```python Python theme={null}
import hashlib
import hmac
import time

def verify(raw_body: bytes, timestamp: str, signature_v2: str, secret: str,
           tolerance_s: int = 300) -> bool:
    try:
        ts = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - ts) > tolerance_s:
        return False
    signed = timestamp.encode("ascii") + b"." + raw_body
    digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest("sha256=" + digest, signature_v2 or "")
```

```javascript Node.js theme={null}
const crypto = require("crypto");

function verify(rawBody, timestamp, signatureV2, secret, toleranceS = 300) {
  const ts = Number.parseInt(timestamp, 10);
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceS) {
    return false;
  }
  const digest = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody) // a Buffer with the exact bytes received
    .digest("hex");
  const expected = Buffer.from(`sha256=${digest}`);
  const received = Buffer.from(signatureV2 || "");
  return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}
```

`X-PolySim-Signature` is the older scheme: HMAC-SHA256 of the raw body alone,
in the same `sha256=<hex>` form. It has no timestamp, so it cannot stop a
replayed request. Prefer `X-PolySim-Signature-V2`.

Use `POST /v1/developer/webhooks/{endpoint_id}/test` to check your receiver. It
sends a signed `webhook.test` event at once and returns what happened. It is not
written to the delivery log:

```json theme={null}
{"success": true, "status_code": 200, "error": null, "duration_ms": 184}
```

`status_code` is `null` when the request never completed (refused URL, DNS
failure, timeout).

***

## Responding, retries and timeouts

1. Answer with any **2xx** status within **10 seconds**. The response body is
   ignored.
2. A delivery is tried up to **5 times**. After a failed attempt the next one is
   scheduled 15 seconds, 60 seconds, 5 minutes and 30 minutes later, so the
   last attempt comes about 36 minutes after the first.
3. **Retried:** `5xx`, `408`, `425`, `429`, `3xx`, timeouts and connection
   errors.
4. **Not retried:** any other `4xx`. The delivery ends at once as `failed_terminal`.
5. An event is picked up for delivery at least 10 seconds after it happens, in
   a background pass that runs every few seconds.

### Duplicates and order

* Deduplicate on `event_id`. Retries and replays send the same event again, and
  if two of your endpoints subscribe to the same type, each receives it.
* Deliveries are sent in parallel and retried independently, so they can arrive
  out of order. Use `ts`, and the `status` inside `order` or `position`, rather
  than arrival order.

***

## Delivery log

```bash theme={null}
curl "https://api.polysimulator.com/v1/developer/webhooks/17/deliveries?limit=50" \
  -H "X-API-Key: $POLYSIM_API_KEY"
```

`limit` is 1 to 200 (default 50). To page back, pass the response's
`next_before_id` as `before_id`; it is `null` on the last page.

Each delivery has `id`, `event_id`, `event_type`, `status`, `attempts`,
`next_attempt_at`, `delivered_at`, `response_code`, `error`, `created_at`, and
`attempts_log` (the last 20 attempts: `attempt_number`, `started_at`,
`duration_ms`, `success`, `response_status`, `error`).

| `status` | Meaning |
| - | - |
| `pending` | Waiting for its first attempt, or queued again by a replay. |
| `sending` | An attempt is in progress. |
| `retry` | The last attempt failed; the next one is due at `next_attempt_at`. |
| `sent` | Your endpoint answered 2xx. |
| `failed_terminal` | No more attempts will be made. |

### Replay a failed delivery

`POST /v1/developer/webhooks/deliveries/{delivery_id}/replay` puts a
`failed_terminal` delivery back in the queue. It keeps its `delivery_id` and its
attempt count: a delivery gets 5 attempts in total, so only one that stopped
early (a non-retried `4xx`, for example) can be replayed.

| Error | Status | When |
| - | - | - |
| `NOT_FOUND` | 404 | No such delivery on your account. |
| `NOT_REPLAYABLE` | 400 | The delivery is not `failed_terminal`. |
| `ATTEMPT_BUDGET_EXHAUSTED` | 400 | It already used all 5 attempts. |
| `ENDPOINT_INACTIVE` | 400 | The endpoint is paused. Resume it first. |
| `REPLAY_CONFLICT` | 400 | The delivery changed state while you replayed it. |

***

## Pause, resume, rotate, delete

* **Pause** (`PATCH` with `{"status": "paused"}`): nothing is sent to the
  endpoint, and events that happen while it is paused are **not** sent later.
* **Resume** (`{"status": "active"}`): deliveries start again with new events.
  Deliveries that were already queued before the pause are sent.
* **Rotate** (`rotate-secret`): the new secret applies at once, to every later
  attempt, including retries of earlier events. There is no period in which the
  old secret still works, so update your receiver right away.
* **Delete**: removes the endpoint and its delivery log. `204`, no body.

If your account loses its Pro or Pro+ plan, queued deliveries stop: each one
ends as `failed_terminal` with the error
`alerts.webhook entitlement no longer active`.


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