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.
Delivery, and what it depends on
HTTP delivery is off unless three separate things are true
Events are emitted and stored durably, and deliveries are queued against registered endpoints with a working retry schedule. The HTTP sender is signed and refuses to send unsigned. Whether it sends depends on all three of: WEBHOOK_DELIVERY_ENABLED set to true, a signing keyring that resolves, and a declared set of allowed destination hosts. Each answers a different question (is this wanted, is it possible, is it bounded), and any one of them alone would make the other two look optional.
Build your consumer against this contract, and do not depend on a delivery arriving until your deployment has the flag on and a destination registered.
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.
Egress is allow-listed, with every non-public address refused. A sender POSTs to a partner-supplied URL from inside our network; without a declared set of legal destinations that is server-side request forgery with a schedule attached. The allow-list is mandatory and a destination is verified when it is registered and again when it changes.
A deployment with no destinations declares that explicitly rather than being misconfigured, so “ready with nothing to deliver” and “broken” are different states you can tell apart.
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.
Nothing is lost either way. An undelivered event is a durable row and it is delivered once the sender is on, rather than discarded. A queue with no drain that looked finished would be the dangerous version.
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. | fillId, your externalUserId, the contractId, this fill’s quantity and price, the order’s running totals, a transmissionState, and operatorMoney with the exact actualDebit and releaseAmount for the order so far. |
order.rejected | The execution layer refuses an order. | orderId, clientOrderId, 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.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. | The affected positionId, the settled and current resolutions, and action: "operator_review_required". |
position.updated has been removed
It was listed here and subscribable, and no code path ever raised one. If you wrote a handler for it, that handler was never going to run. It is gone from the event union, from the subscription list and from the OpenAPI document, and a registration that asks for it is now refused rather than accepted in silence.
Nothing is lost. A position changes in exactly three ways and each already 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. Those three carry the money block you book from, which a position-level event would not have.
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.
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": "ord_...",
"clientOrderId": "your-order-id",
"externalUserId": "your-player-id",
"fillId": "fil_...", // 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
}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 is not yet yours to hand back: 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": "ord_...",
"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": "ord_...",
"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": "ord_...",
"externalUserId": "your-player-id",
"cancellationId": "cxl_ord_...", // 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": "pos_...",
"settlementId": "set_...", // 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 cancelled market’s refund, fee included
},
"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 reverses one, and collapsing them into a single payout would push that distinction onto you to re-derive from the outcome string.
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
predicta-signature: t=1755751331,v1=<hex hmac-sha256 of "<t>.<body>">
predicta-event-type: position.settled
user-agent: Parity-Webhooks/1dedupeKeyis 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. The fact's own time is insidedata.
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. There is no
/api/v1endpoint that registers a webhook destination. An endpoint is registered for you, and the signing secret is returned once at that moment and never again by any read. Store it in your secret manager immediately. Rotating it issues a new one the same way, and the old ciphertext stays readable so nothing in flight is stranded. - Verify against the raw body, before any JSON parsing. Re-serialising changes the bytes and the MAC will not match.
- Verify either
parity-signatureorpredicta-signature. Both headers carry the same signature over the same body, so checking one is enough; the second name is there for endpoints written against the earlier docs. - 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 delivery is marked failed 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 |
- Six attempts over 22 hours 35 minutes. These are the delays a delivery is measured walking, not the ones the schedule was described as producing. The two differed by a step until a test went and checked.
- 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.
exhaustedis a distinct terminal state fromfailed: it means Parity stopped trying, which is something an operator needs to be able to find and replay rather than silently lose.
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 on an explicit allow-list, which fails closed.
- 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.
Known limitation, stated plainly
DNS rebinding is not closed. The hostname is resolved for the check and resolved again when the connection is made, and the answer can change in between. Closing it requires pinning the socket to the verified address, or an egress proxy that enforces the destination. Neither exists yet, which is why the allow-list is mandatory and why a destination you have not vetted should not be on it.

