# Dead-man switch

Arm a heartbeat timer with CancelAllAfter so your open orders are canceled automatically if your system stops refreshing it.

The dead-man switch cancels your open orders when your system stops sending heartbeats. Arm it with [`OrdersService.CancelAllAfter`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/CancelAllAfter) and a timeout, then call it again before the timeout runs out. If a heartbeat does not arrive in time, Polyester cancels every open order in the switch's scope. It protects resting quotes when a trading process crashes, hangs, or loses its network.

The switch is a timer, not a connection monitor. Closing a connection does not trigger it, and an open connection does not keep it alive: only a `CancelAllAfter` call refreshes it.

## How the switch works

- **One switch per account.** The Main Account and each Subaccount have their own switch. Arm it for a Subaccount with `subaccountId`; a switch on the Main Account does not cover its Subaccounts.
- **A timeout from 10 to 120 seconds arms or refreshes it.** Each call starts the countdown again from the moment Polyester processes it and replaces the previous timeout and scope.
- **A timeout of `0` disarms it.** Disarming cancels nothing.
- **Expiry cancels once, then the switch ends.** When the deadline passes, Polyester cancels every open order in the scope that exists at that moment, including orders that triggers placed. Each ends `CANCELED` with cancel reason `CANCEL_ALL_AFTER`. The switch is then gone until you arm it again.

## Arm or refresh the switch

`CancelAllAfter` is available over REST ([`POST /v1/orders/cancel-all-after`](https://testnet.polyester.com/docs/api-docs/rest/POST/v1/orders/cancel-all-after)) and ConnectRPC ([`/orders.v1.OrdersService/CancelAllAfter`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/CancelAllAfter)). The request rules are:

| Field                 | Rule                                                                                                                                     |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `timeoutSec`          | `0` disarms; `10` to `120` arms or refreshes                                                                                             |
| `symbol` / `symbolId` | Optional. Limits the switch to one Pair: a symbol in REST, a Pair ID in ConnectRPC. Omit for all Pairs                                   |
| `side`                | Optional. `BUY` or `SELL` limits the switch to one side; omit for both                                                                   |
| `subaccountId`        | Optional. The Subaccount whose switch to set. Omit for a Subaccount-scoped API key's bound Subaccount, or else the caller's Main Account |
| `requestId`           | Required. 1 to 64 ASCII letters, numbers, `.`, `_`, `:`, `/`, or `-`                                                                     |

An unknown Pair is rejected with `UNKNOWN_SYMBOL`. Any listed Pair is accepted, whatever its trading status.

## REST JSON

```json
{
	"timeoutSec": 30,
	"symbol": "BTC-USDT",
	"requestId": "dms-hb-000417"
}
```

## ConnectRPC ProtoJSON

```json
{
	"timeoutSec": 30,
	"symbolId": 1,
	"requestId": "dms-hb-000417"
}
```

### Response

The response describes the switch now in force. `status` is `ARMED` after an arm or refresh and `DISABLED` after a disarm. `expiresAtTsNs` is when the switch fires, in nanoseconds since the epoch (UTC). After a disarm, `effectiveTimeoutSec` and `expiresAtTsNs` are `0` and `effectiveScope` is absent. In `effectiveScope`, `symbolId` `0` means all Pairs and an omitted `side` means both sides.

```json
{
	"status": "ARMED",
	"effectiveTimeoutSec": 30,
	"expiresAtTsNs": "1793707230000000000",
	"effectiveScope": { "accountId": "3vQB7B6MrGQZaxCuFg4oh", "symbolId": 1 },
	"ts": "2026-11-03T12:00:00.000Z",
	"tsNs": "1793707200000000000"
}
```

Read `effectiveScope` to confirm what the switch covers now. It describes the current switch only; cancellations that a previous expiry already started are not affected by a later refresh or disarm. REST returns `accountId` as a public ID and `symbolId` as the numeric Pair ID.

> **Use a new requestId for every heartbeat**
>
> `requestId` makes a single call safe to retry. A call that repeats a `requestId` used in the last two minutes, with the same parameters, can return the earlier answer without refreshing the timer, so the switch can fire while you believe it is refreshed. The same `requestId` with different parameters is rejected with `CONFLICT_IDEMPOTENCY_KEY_REUSE`. Give every heartbeat its own `requestId`.

## Scope

Each arm or refresh sets the scope anew, so send the same `symbol` and `side` on every heartbeat unless you mean to change them. A refresh with a narrower scope stops covering the orders it leaves out.

Because the switch is one per account, you cannot run separate timers for different Pairs of the same account. Use one switch for all Pairs, or arm switches on separate Subaccounts.

If the switch has just expired and its cancellation has not started yet, a new arm must cover at least the expired scope: all Pairs or the same Pair, and both sides or the same side. A narrower arm is rejected with `CANCEL_ALL_AFTER_SCOPE_CONFLICT`. Retry with a wider scope, or shortly afterwards.

## Cancel-all and triggers

The switch and [Cancel all orders](https://testnet.polyester.com/docs/developer-docs/shared-concepts/cancel-all-orders) are independent. A cancel-all does not disarm the switch, and disarming the switch cancels nothing.

When the switch expires, it treats triggers exactly as [a cancel-all with the same scope](https://testnet.polyester.com/docs/developer-docs/shared-concepts/cancel-all-orders#what-happens-to-triggers) does: it cancels the orders that triggers placed, never the triggers themselves.

## Errors

| Code                              | What to do                                                                                                                                                           |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ORDER_CANCEL_OUTCOME_UNKNOWN`    | The switch may or may not have been armed, refreshed, or disarmed. Send the heartbeat again with a new `requestId`                                                   |
| `CANCEL_ALL_AFTER_SCOPE_CONFLICT` | A just-expired switch has not finished; arm with a scope that covers it                                                                                              |
| `UNKNOWN_SYMBOL`                  | Correct the Pair                                                                                                                                                     |
| `CONFLICT_IDEMPOTENCY_KEY_REUSE`  | The `requestId` was used with other parameters; use a new one                                                                                                        |
| `RATE_LIMIT_EXCEEDED`             | Back off and retry. Each call costs 1 on the cancel budget; see [Rate limits](https://testnet.polyester.com/docs/developer-docs/shared-concepts/rate-limits#trading) |

A timeout other than `0` or `10` to `120` is rejected as invalid. An API key needs spot trading permission to call `CancelAllAfter`. Full codes are in the [error catalogue](https://testnet.polyester.com/docs/developer-docs/error-handling/error-codes).

## Recommended market-maker usage

1. Arm the switch before you quote

   Choose a timeout that covers a short outage of your system but not a long one, and arm the switch before your first order. Check `effectiveScope` in the response.

2. Refresh well before expiry

   Send a heartbeat several times per timeout, for example every third of it, each with a new `requestId`. Refresh from the same process that manages your quotes, so the heartbeat stops when that process stops.

3. Treat a late heartbeat as an expiry

   If a heartbeat succeeds after the previous `expiresAtTsNs`, the switch may have fired in between, and the new call armed a fresh one. Reload open orders before you continue quoting, and watch for `CANCEL_ALL_AFTER` in your [private order updates](https://testnet.polyester.com/docs/developer-docs/connectrpc/private-websocket).

4. Disarm on a planned shutdown

   After canceling your orders, send `timeoutSec` `0`. Otherwise the old timer can still fire and cancel orders that your next session places before it sends its own first heartbeat.

> **Not available on the SBE WebSocket**
>
> The SBE WebSocket has no dead-man switch command. Arm and refresh the switch over REST or ConnectRPC; the orders it cancels still report `CANCELED` with `CANCEL_ALL_AFTER` in SBE `OrderEvent`.
