Lifecycle
Webhooks
Events are facts, recorded once. Deliveries are attempts to tell you about them. Retrying the second never re-runs the first, which is the property that stops a retry from paying a player twice.
How delivery works
Durable, signed, verified at every attempt
Every event is recorded durably the moment it happens, and a delivery is queued for each registered endpoint that subscribes to its type. Each delivery is signed, and an unsigned delivery is never sent. Deliveries retry on the schedule below until your endpoint acknowledges them.
Signing secrets are encrypted at rest under a versioned keyring. Each ciphertext names the key that produced it, older keys stay able to decrypt, and a rotation re-encrypts deliberately, so turning a key over does not strand the secrets already stored under the last one.
Destinations are verified, with every non-public address refused. Parity only delivers to hosts registered for your account, and a destination is verified when it is registered, again when it changes, and again on every delivery attempt.
Never make a webhook the only way you learn about money. Delivery is a convenience; /api/v1/reconciliation is the authoritative record and is designed to be polled. An integration that books a credit only on a webhook will eventually miss one.
A queued delivery is a durable row, and it is retried until it is acknowledged or its attempts are used up. An endpoint receives the events raised while it is registered, active and subscribed to that type: an event raised while your endpoint is inactive, or not subscribed to that type, is not queued to it. A newly registered endpoint starts from the moment it is registered. The reconciliation feed holds the full history, so it is how you catch up on anything earlier.
Poll the record
Poll GET /api/v1/settlements. It carries the same fact as position.settled and keys on the same positionId, so a partner may use either without processing it twice, and can move from one to the other without changing how the credit is deduplicated. For everything else, the reconciliation feed is a totally ordered, resumable stream of every money fact.
Event types
| Type | Emitted when | What it carries |
|---|---|---|
order.intent_recorded | A live order is recorded and reserved against, with nothing sent to a venue. | orderId, your clientOrderId, your externalUserId, and an exact authorizationAmount with a currency. RESERVE AND HOLD that amount. This is the event a back office needs while an order waits to be transmitted: funds are held and no venue has heard of it. |
order.filled | A FILL is recorded. One event per fill, not per order. | orderId, clientOrderId, fillId, your externalUserId, the contractId, side and action (buy or sell), this fill’s quantity and price, the order’s running totals and status, a transmissionState, operatorMoney (a buy) or operatorExitMoney (a sell) with the cumulative figures for the order so far, simulated, and fillSequence, partial, final and fillMoney for this fill. |
order.rejected | The execution layer refuses an order. | orderId, clientOrderId, executionRef, your externalUserId, the reason, and operatorMoney whose releaseAmount is the whole authorization you are still holding. Null on a sell: an exit reserves no cash. |
order.completed | The venue closes an order it could still have filled: fully matched, cancelled with some filled, or closed with none. One event per order. | orderId, clientOrderId, your externalUserId, the contractId, side, action, the final status and outcome, the order’s totals, final: true, operatorMoney (a buy) or operatorExitMoney (a sell) as the order finally stands, and releaseAmount, the unspent authorization to hand back. |
order.cancelled | An untransmitted order is withdrawn. | A cancellationId to key the release on, plus the whole money identity: authorizationAmount, an actualDebit of 0.00, and the releaseAmount to hand back. |
position.settled | A position settles against its contract’s resolution. | positionId, settlementId (the credit’s idempotency key), your externalUserId, and operatorMoney with settlementCredit and voidCredit as exact decimal strings. Exactly one is non-zero; a loss zeroes both. |
market.resolution_changed | A resolution changes after positions had already settled against it. | contractId, settledResolution, currentResolution (null when the outcome was withdrawn), affectedPositionIds (every position of yours it touches), affectedPositionId (the first of them), and action: "operator_review_required". |
Swipe the table sideways for more columns.
Seven event types, and every change to a position is covered
These seven are the complete set, and a registration names them exactly. A position changes in exactly three ways and each has its own event: an entry fill and an exit fill are both order.filled (read action to tell them apart), and the resolution is position.settled. All three carry the money block you book from. When the venue closes an order, order.completed carries that block as it finally stands, with the amount to release.
Every money event names the wallet and the amount
Each event that implies a movement carries externalUserId, which is your id for the customer, and an amount as an exact decimal string with a currency. The field names are the same ones /api/v1 uses, so a wallet that already applies operatorMoney from a quote or an order applies it here unchanged.
Float fields such as payout, fillPrice and costBasis are still beside them and are still facts to read. They are not what you book. A JSON number cannot carry an exact cent past a certain size and, worse, looks like it can.
Payloads are narrowed to the partner form exactly as the REST surface is: opaque ctr_… contract ids and no source named anywhere. That now holds where it is decided, at the point an event is recorded, rather than being promised of a delivery step. The dedupeKey is narrowed with the body, because it is published verbatim in the envelope and again on the reconciliation feed.
market.resolution_changed carries no instruction
It reports that a source changed its mind about something you were already paid on. No money has moved and none will move automatically. One event is emitted per change, not per sweep, so a job running every five minutes does not raise the same alarm twelve times an hour.
It names every position it touches. The event is keyed on the contract and the change, per operator, so when several of your positions settled against the same resolution you receive one event, and affectedPositionIds lists all of them. affectedPositionId is the first of that list and stays for integrations that already read it. An event recorded before affectedPositionIds existed does not carry it, because a retry resends the stored event unchanged; for one of those, review every position you settled on the contractId with GET /api/v1/settlements.
What each payload looks like
Every shape below is the object that is actually stored on the event and serialised into data. They are published in the OpenAPI document as well, one schema per event type.
order.filled is keyed on the fill, not the order. An order that fills in pieces raises one event per piece, and dedupeKey is order.filled:<orderId>:<fillId>. operatorMoney is cumulative for the order, never per fill: an amount stamped on each fill is an amount you can debit twice.
{
"orderId": "3dd27f36-7780-4aa7-8af2-1a6854fbf8cb",
"executionRef": "Parity_Best_7K2M9QH4TR8DXZ", // quote this to Parity support
"clientOrderId": "your-order-id",
"externalUserId": "your-player-id",
"fillId": "9b1e4c2a-0f6d-4e38-a5b7-6c2d8e1f3a90", // this fill. the tail of dedupeKey
"contractId": "ctr_4919562d19a0f299",
"side": "YES",
"action": "buy",
"fillQuantity": 19, // THIS fill
"fillPrice": 0.52, // THIS fill
"filledQuantity": 19, // the ORDER, to date
"averagePrice": 0.52, // the ORDER, to date
"status": "partially_filled",
"transmissionState": "unknown", // read this BEFORE releasing anything
"operatorMoney": { // cumulative for the ORDER
"currency": "USD",
"authorizationAmount": "25.00",
"actualDebit": "10.00",
"releaseAmount": "15.00",
"tradeAmount": "9.88",
"platformFee": "0.12",
"venueFee": "0.00",
"venueFeeKnown": false
},
"operatorExitMoney": null, // the sell leg. null on a buy
"simulated": false,
"fillSequence": 1, // 1 for the first fill, 2 for the second...
"partial": true, // true until known to have filled in full
"final": false, // true once no further fill can arrive
"fillMoney": { // THIS fill only. itemisation, not what you book
"currency": "USD",
"tradeAmount": "9.88",
"platformFee": "0.12",
"venueFee": "0.00",
"venueFeeKnown": false,
"actualDebit": "10.00", // on a buy. null on a sell
"sellProceeds": null // on a sell. null on a buy
}
}Every amount is an exact decimal string in major units of its currency ("10.00" is ten dollars), never a JSON number. Quantities and prices stay numbers: they are facts you read, not amounts you book.
Book from operatorMoney, never from fillMoney. For each order, keep what you have already booked and the highest fillSequence you have applied. When an event arrives with a higher fillSequence, book the difference between its operatorMoney.actualDebit (or operatorExitMoney.sellProceeds on a sell) and what you already booked; ignore an event with a lower one, because a retried delivery can arrive after a later fill. This is idempotent and it heals itself: every event carries the order’s full total, so one lost event is made good by the next. Poll GET /api/v1/orders/{id} for the same figures.
fillMoney is itemisation: what this one fill moved, for a statement line or a reconciliation. It is not a booking source. An event can be lost if our process stops between recording a fill and emitting its event, and a fill we have already recorded is never announced twice, so a sum of fillMoney can have a gap that nothing fills in. When no event is missing it adds up to the cumulative block to the cent. The release of any unspent authorization is only ever in operatorMoney.releaseAmount.
fillSequence, partial, final and fillMoney are absent on events created before they were introduced. A retry resends the stored event exactly as it was first written, so treat all four as optional.
partial and final say outright what status and transmissionState imply. final is true exactly when transmissionState is resolved. partial stays true until the order is known to have filled in full, so partial with final is an order that closed short and has a remainder to release. Orders are placed as a cash amount, not a contract count, so what remains unfilled is expressed as money: operatorMoney.releaseAmount.
The push that says the order is finished is order.completed. On an order sent to the market, each fill is recorded while the order is still open, so its order.filled carries partial: true and, in almost every case, final: false. When the venue then closes the order, one order.completed is raised with final: true, the cumulative block as it finally stands and the releaseAmount to hand back. Book the last delta from it and apply the release. Like any event it can be missing, so an order that has taken fills and has not completed is still worth a read of GET /api/v1/orders/{id}: once transmissionState is resolved, apply the release it shows.
Do not release the remainder while transmissionState is unknown
A partial fill is the one case where the debit is not the authorization, which is exactly why it is worth an event. It is also the case where releaseAmount is real money that you keep holding for now: the venue can still fill more of the order.
transmissionState answers that and nothing else. unknown means hold the authorization and poll. resolved means no further fill can arrive, so the release is safe to apply. The same field is on GET /api/v1/orders/{id} and means the same thing.
{
"orderId": "3dd27f36-7780-4aa7-8af2-1a6854fbf8cb",
"executionRef": "Parity_Best_7K2M9QH4TR8DXZ", // quote this to Parity support
"clientOrderId": "your-order-id",
"externalUserId": "your-player-id",
"contractId": "ctr_4919562d19a0f299",
"side": "YES",
"action": "buy",
"status": "partially_filled",
"outcome": "closed_partial", // complete | closed_partial | closed_unfilled
"filledQuantity": 24.0,
"averagePrice": 0.72,
"fillCount": 2,
"transmissionState": "resolved",
"final": true,
"partial": true,
"operatorMoney": { // the order's last word
"currency": "USD",
"authorizationAmount": "25.00",
"actualDebit": "17.49",
"releaseAmount": "7.51",
"tradeAmount": "17.28",
"platformFee": "0.21",
"venueFee": "0.00",
"venueFeeKnown": false
},
"operatorExitMoney": null,
"releaseAmount": "7.51", // HAND THIS BACK
"currency": "USD",
"simulated": false
}{
"orderId": "3dd27f36-7780-4aa7-8af2-1a6854fbf8cb",
"executionRef": "Parity_Best_7K2M9QH4TR8DXZ", // quote this to Parity support
"clientOrderId": "your-order-id",
"externalUserId": "your-player-id",
"reason": "venue refused: market closed",
"operatorMoney": { // nothing was spent. release all of it
"currency": "USD",
"authorizationAmount": "25.00",
"actualDebit": "0.00",
"releaseAmount": "25.00",
"tradeAmount": "0.00",
"platformFee": "0.00",
"venueFee": "0.00",
"venueFeeKnown": false
}
}{
"orderId": "3dd27f36-7780-4aa7-8af2-1a6854fbf8cb",
"executionRef": "Parity_Best_7K2M9QH4TR8DXZ", // quote this to Parity support
"clientOrderId": "your-order-id",
"externalUserId": "your-player-id",
"authorizationAmount": "40.00", // RESERVE and HOLD. there is no release here
"currency": "USD",
"transmitted": false
}There is deliberately no releaseAmount on a recorded intent. Nothing has been decided yet, and a field meaning “hand this back” on an order whose funds must be held would be the wrong instruction at the wrong moment. order.cancelled is the event that says release.
{
"orderId": "3dd27f36-7780-4aa7-8af2-1a6854fbf8cb",
"executionRef": "Parity_Best_7K2M9QH4TR8DXZ", // quote this to Parity support
"externalUserId": "your-player-id",
"cancellationId": "cxl_3dd27f36-7780-4aa7-8af2-1a6854fbf8cb", // idempotency key for the release
"currency": "USD",
"authorizationAmount": "40.00",
"actualDebit": "0.00", // always zero: nothing was transmitted
"releaseAmount": "40.00",
"reason": "customer withdrew",
"transmitted": false
}{
"positionId": "5c0f7a1e-2b9d-4c6e-8f31-7a4d2e9b6c05",
"settlementId": "e2a94d17-6c3b-4f80-9d52-1b7e3a6c8f24", // idempotency key for the credit
"externalUserId": "your-player-id",
"contractId": "ctr_4919562d19a0f299",
"side": "YES",
"resolution": "YES",
"outcome": "won",
"contracts": 15.6451,
"costBasis": 10.00, // facts, not instructions
"entryFees": 0.30,
"payout": 15.65,
"realisedPnl": 5.35,
"lifetimeRealisedPnl": 5.35,
"operatorMoney": { // CREDIT THIS
"currency": "USD",
"settlementCredit": "15.65", // winnings
"voidCredit": "0.00" // a void: a refund, fee included, or a split payout
},
"simulated": false
}Exactly one of settlementCredit and voidCredit is non-zero, and a loss zeroes both. They are two names because they are two instructions: winnings are revenue against a wager and a void either reverses one (an annulled market, fee included) or pays a split result at its share of a dollar a contract with the fee kept. Collapsing them into a single payout would push that distinction onto you to re-derive from the outcome string.
{
"contractId": "ctr_4919562d19a0f299",
"settledResolution": "YES", // what you were settled on
"currentResolution": "NO", // null when the outcome was withdrawn
"affectedPositionIds": [ // every position of yours it touches
"5c0f7a1e-2b9d-4c6e-8f31-7a4d2e9b6c05",
"9a2e4b7c-1d3f-4e5a-8b6c-0f9e8d7c6b5a"
],
"affectedPositionId": "5c0f7a1e-2b9d-4c6e-8f31-7a4d2e9b6c05", // the first of them
"action": "operator_review_required"
}The dedupe key of each event
| Type | dedupeKey |
|---|---|
order.intent_recorded | order.intent_recorded:<orderId> |
order.filled | order.filled:<orderId>:<fillId> |
order.rejected | order.rejected:<orderId> |
order.cancelled | order.cancelled:<orderId> |
order.completed | order.completed:<orderId> |
position.settled | position.settled:<positionId> |
market.resolution_changed | market.resolution_changed:<contractId>:<from>-><to>:<operator> |
Swipe the table sideways for more columns.
Treat every key as opaque and compare it whole. The formats are shown so you can see what makes two events the same fact, not so you can parse them. The <operator> part is an opaque token that is stable for your operator; events recorded before it was introduced carry your operator id there instead, and are never raised a second time under the new form.
Any event can be missing, not only a fill
An event is recorded just after the change it describes is committed. If our process stops in between, the change stands and its event is never raised. That applies to all seven types. It is rare, it is the reason no webhook may be your only source of money facts, and it is why every money event carries the full cumulative figures rather than a delta: the next event, or a read of the order, heals the gap.
The delivery envelope
{
"id": "<eventId>",
"type": "position.settled",
"dedupeKey": "position.settled:<positionId>",
"createdAt": "2026-08-21T18:02:11.004Z",
"data": { ... }
}content-type: application/json
parity-signature: t=1755751331,v1=<hex hmac-sha256 of "<t>.<body>">
parity-event-type: position.settled
user-agent: Parity-Webhooks/1The event is identified by id in the body, which is the same on every attempt, so no per-attempt header is needed. The signature timestamp t is taken afresh for each attempt, so a retry carries a new signature over the same data.
dedupeKeyis the natural key of the fact, not of the attempt to report it. Key your own processing on it. At-least-once is the only guarantee an HTTP retry can offer, and this is what makes that safe.- Answer
2xxquickly and process asynchronously. A delivery times out at 10 seconds. - Redirects are never followed, and a
3xxis treated as a delivery failure. A redirect is a destination nobody verified. createdAtis stamped when this attempt is sent, not when the fact happened. It moves between retries of the same event, so it is a delivery timestamp and must not be used to order events or to date the underlying fact. No payload carries a timestamp of its own: date the fact from the resource it names (for exampleGET /api/v1/orders/{id}) or from the reconciliation feed.
Deliveries are not ordered
There is no ordering guarantee, between event types or within one. Deliveries are drained in batches by whichever worker claims them, a failed one is retried minutes or hours later, and two events about the same order can therefore arrive in either order or a long way apart.
So do not build a state machine that advances on arrival order. Treat each delivery as a notification that something changed, key it on dedupeKey, and read the current state back from the API before acting on money. Where order matters, the reconciliation feed is totally ordered and is the surface built for it.
Verifying a signature
The timestamp is inside the signed material, not merely alongside it. A signature over the body alone is replayable forever: anyone who observes one valid delivery can resend it a year later and it still verifies. The tolerance is 300 seconds.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret: string, body: string, header: string, toleranceSeconds = 300) {
const parts = new Map(header.split(',').map((p) => p.split('=') as [string, string]));
const t = Number(parts.get('t'));
const v1 = parts.get('v1');
if (!Number.isFinite(t) || !v1) return false;
// An old signature is refused even when the MAC is perfect. That is the whole
// purpose of putting the timestamp in the signed material.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest();
const given = Buffer.from(v1, 'hex');
if (given.length !== expected.length) return false;
return timingSafeEqual(expected, given);
}- Where the secret comes from. Parity registers your endpoint and issues its signing secret once, at that moment; no read returns it again. Store it in your secret manager immediately. Rotating it issues a new one the same way and replaces the old one at once. There is no overlap window: every delivery from that moment, including retries of events raised before it, is signed with the new secret. Plan a rotation as a short outage of verification, and deploy the new secret as soon as it is issued; deliveries that fail verification in between are retried on the normal schedule.
- Verify against the raw body, before any JSON parsing. Re-serialising changes the bytes and the MAC will not match.
- Verify
parity-signature. Each delivery also carries the same signature under an earlier header name, for endpoints written against the earlier docs, so checking one is enough. - Signing secrets are prefixed
whsec_and are encrypted at rest under a key held outside the database. - An unsigned delivery is never sent. If signing is unavailable the attempt counts as a failure and is retried on the schedule below, rather than sent in the clear.
Retries
| Attempt | Delay after the previous failure |
|---|---|
| 1 | immediate |
| 2 | 5 minutes |
| 3 | 25 minutes |
| 4 | 2 hours 5 minutes |
| 5 | 10 hours |
| 6 | 10 hours, and the last. If it fails, the delivery is marked exhausted |
Swipe the table sideways for more columns.
- Six attempts over 22 hours 35 minutes, measured end to end by a test that walks a delivery through the whole schedule.
- Every failed attempt uses one of the six, whatever the cause: a non-2xx answer, a timeout or network error, a redirect, a destination that fails the checks below, or signing being unavailable. A destination whose host is not registered with Parity therefore exhausts after the full schedule.
- The first attempt goes out on the next drain of the queue, which runs every 15 seconds by default. A delivery claimed by a worker that then dies is picked up again after 30 minutes, which is one more reason a delivery can arrive late or twice.
- An endpoint stays registered however many deliveries fail. A delivery to an endpoint that has been made inactive is held, not dropped, and resumes if the endpoint is made active again.
- The tail is deliberately long. An endpoint that has been down for ten hours is having an incident, and hammering it every minute helps nobody.
- A delivery is
pending,sending,deliveredorexhausted.exhaustedis terminal for the schedule, and the delivery is kept: Parity replays any exhausted delivery on request.
Four answers stop the retries immediately
404, 405, 410 and 501 are statements about whether your endpoint exists and what it can do, and no amount of waiting changes one. A delivery that gets any of them goes straight to exhausted on the first attempt, with the status code and the body recorded on the row, instead of walking a full day of backoff to reach the same place.
Everything else keeps all six attempts, deliberately. 5xx and 408 say they are temporary, 429 asks us to slow down rather than stop, and 401 / 403 is a proxy in front of a working endpoint rather than a credential of ours being wrong. 400 and 422 also keep retrying: they are the code you have deployed right now refusing a body, and if you ship a fix within the day the delivery is still waiting for you.
Destination requirements
A partner-supplied URL is an attacker-influenced outbound request from inside Parity's network, so the destination rules are strict and are re-checked on every attempt rather than once at registration, because DNS can be repointed afterwards and a retry hours later is exactly when that would be exploited.
httpsonly, on port 443. No credentials in the URL.- The hostname must be registered with Parity for your account. Any other host is refused.
- Every resolved address must be public unicast. Loopback, private ranges, link-local (cloud instance metadata), carrier-grade NAT and multicast are all refused, and every address is checked, not just the first.
DNS rebinding is closed. The connection is made to an address the check validated, the name is not resolved a second time, TLS is verified against the hostname, and redirects are not followed.
A destination that fails a check is refused with one of these reasons:
| Reason | Meaning |
|---|---|
malformed_url | The URL does not parse. |
scheme_not_https | Only https is delivered to. |
credentials_in_url | A user name or password is in the URL. |
port_not_443 | Any port other than 443 (an explicit :443 is fine). |
host_not_allow_listed | The host is not registered with Parity for your account. A wildcard entry *.example.com covers subdomains but not example.com itself. |
private_address | A resolved address is not public unicast. |
dns_failed | The name did not resolve. |
dns_empty | The name resolved to no address. |
Swipe the table sideways for more columns.
Refused ranges: 0.0.0.0/8, 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 100.64.0.0/10, the documentation ranges 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24, and everything from 224.0.0.0 up; for IPv6, ::, ::1, fe80::/10, fc00::/7, ff00::/8, 2001:db8::/32 and 3fff::/20. An IPv4 address written inside IPv6 is unwrapped and checked as IPv4.
Registering and managing an endpoint
Webhook endpoints are a managed service. Send your webhook URLs and event types to your account team; we register them and confirm. Signed-in members of your team with write access can also manage them on your account's configuration page (the Webhook endpoints section). Per endpoint, we need:
- The URL. It must pass the destination rules above, and its host is registered with Parity for your account as part of registering the endpoint.
- The event types it wants. An empty list means every type, including any added later.
| Action | What it does |
|---|---|
| Register | Creates the endpoint and returns its whsec_ signing secret once. It is never shown again. |
| Change URL | Re-verifies the new destination before it is saved. |
| Change events | Replaces the subscribed list. Takes effect for events raised afterwards. |
| Activate / deactivate | An inactive endpoint is queued nothing new; deliveries already queued to it wait. |
| Rotate secret | Issues a new secret, shown once, and replaces the old one immediately. |
| Replay | Re-sends one event to one endpoint: its attempt count resets to zero and it goes out on the next drain. Refused while that delivery is in flight or the endpoint is inactive. |
Swipe the table sideways for more columns.
Every attempt is recorded per endpoint with its status code, so when a delivery has not arrived your account team can tell you exactly what your endpoint answered, and replay it.

