The dead-man switch cancels your open orders when your system stops sending heartbeats. Arm it with 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
0disarms 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
CANCELEDwith cancel reasonCANCEL_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) and ConnectRPC
(/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.
{
"timeoutSec": 30,
"symbol": "BTC-USDT",
"requestId": "dms-hb-000417"
}{
"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.
{
"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.
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 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 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 |
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.
Recommended market-maker usage
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.
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.
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.
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.
CANCELED with CANCEL_ALL_AFTER in SBE OrderEvent.