Webhooks
Server-to-server event delivery for lifecycle events that matter to your systems. Where the realtime socket keeps a connected client's state warm, webhooks are the durable channel: events your backend must not miss even while disconnected.
Envelope
Every v2 webhook shares one envelope. The protocol version is declared on the wire, and eventId is your idempotency key — a redelivered event reuses the same id, so dedupe on it.
| Field | Type | Description |
|---|---|---|
version | String! | Protocol version — "2" |
eventId | UUID! | Unique per event; identical across redeliveries. Dedupe on this |
eventType | String! | ORDER_SUBMISSION_EVENT, ORDER_EXECUTION_EVENT, or SHIPMENT_EVENT |
occurredAt | DateTime! | When the underlying transition happened — not when this delivery was attempted |
accountId | UUID! | The account this delivery is for |
payload | Object! | The event body — shape per eventType, below |
Events are not guaranteed to arrive in order. Every payload carries a full resource snapshot, so applying the latest snapshot per resource is always safe — but "latest" means the highest updatedAt on the snapshot, not the one that arrived last. Reconcile on updatedAt and discard any snapshot older than the one you already hold.
ORDER_SUBMISSION_EVENT
One event type for the whole order lifecycle, discriminated by event:
event | Fires when |
|---|---|
RESTING | The order is live on the book |
FILL | A resting order accrued a fill — cumulative progress in filledQuantity / remainingQuantity |
AMENDED | Order terms changed — yours, or a market configuration reprice |
PARTIALLY_FILLED | Some quantity delivered while the remainder still rests. Not terminal |
FILLED | Fully filled and settled |
CANCELLED | The resting quantity was pulled — reason says why; errorCode accompanies rejections. status on the event may still read SETTLING while earlier fills deliver, and filledQuantity may be non-zero |
EXPIRED | Time in force or expiry elapsed on the resting quantity. Like CANCELLED, filledQuantity may be non-zero |
There is no event for QUEUED (that is the synchronous createOrderSubmission response), EXECUTING (a millisecond worker claim, not a business phase), or SETTLING (visible as status inside fill event snapshots).
An amend emits two events: AMENDED carrying the new terms, then a fresh RESTING as the order is re-rested on the book at those terms. Both carry the same submission.id — this is one order re-priced, not a second order. Key your state on submission.id and the pair collapses correctly.
A FILL here and an ORDER_EXECUTION_EVENT with event: CREATED describe the same cross from two angles. Subscribing to both families and counting each as a fill double-counts.
| Payload field | Type | Description |
|---|---|---|
event | String! | Discriminator, table above |
reason | String | Why the transition fired, e.g. USER_CANCELLED, BULK_CANCELLED, FOK_UNFILLABLE, REJECTED, MAKER_FILL, SETTLEMENT_ROLLUP |
errorCode | String | Populated when reason is REJECTED |
filledQuantity | Int | Cumulative units matched. Null on events that carry no fill information |
remainingQuantity | Int | qty minus filledQuantity; null whenever filledQuantity is null |
settledQuantity | Int | Cumulative units delivered — settlement events only. Trails filledQuantity while a leg is in flight, and stays behind it when a leg failed |
submission | OrderSubmission! | Full snapshot — the OrderSubmission base shape |
Matched versus settled quantity
The two counters are the same pair getOrderSubmission reports. filledQuantity moves at match time and never decreases; settledQuantity arrives with SETTLEMENT_ROLLUP and is the number to bill and fulfil against. settledQuantity < filledQuantity on a terminal order means some of the match never completed.
Both counters are cumulative-to-date, not deltas. Do not accumulate them across events.
Orders created through the legacy conditional-bid API carry an orderSubmissionId; these events fire for those orders too, so you can consume v2 order events before migrating your write path.
ORDER_EXECUTION_EVENT
Each fill delivers to both parties independently; side says which leg was yours.
event | Fires when | Terminal |
|---|---|---|
CREATED | The cross happened — the fill notification | no |
DISPUTED | The buyer raised a dispute; settlement is frozen until it resolves | no |
DISPUTE_RESOLVED | The dispute closed and the fill went back to settling | no |
SETTLED | Money and inventory movement completed | yes, but see below |
FAILED | Settlement failed — failureCode says which kind | yes |
REVERSED | Unwound after settlement | yes |
Neither PENDING nor SETTLING earns an event — CREATED already announces the cross, and the rest is observable as status in snapshots. A dispute does get both edges, because it can freeze a trade indefinitely — without them a partner's last event would be CREATED and they would wait on a settlement that is not coming.
SETTLED is not the last wordA REVERSED can arrive long after a fill settled — a refund or clawback unwinding a completed trade. If you book revenue or release goods on SETTLED, make that reversible rather than treating the event as final.
Two events, one trade
Each party gets their own delivery: two envelopes, two different eventIds, opposite side values. They are distinct events, not one event delivered twice — do not let your eventId dedupe collapse them.
A single cross also emits across two event families. The resting side receives an ORDER_SUBMISSION_EVENT with event: FILL, and both sides receive this ORDER_EXECUTION_EVENT with event: CREATED. Same economic moment, two notifications. If you subscribe to both families and count each as a fill, you will double-count.
Join them on the order ids: execution.buySideOrderId and execution.sellSideOrderId are OrderSubmission ids, matching submission.id on the submission event.
side versus takerSide
Two fields on the same delivery answer different questions, and both are BUY or SELL:
| Field | Where | Answers |
|---|---|---|
side | payload | Which leg you were on this fill |
takerSide | inside execution | Which side crossed the book — the aggressor |
They routinely disagree. If your resting bid is hit by an incoming sell, you receive side: "BUY" and takerSide: "SELL" on the same event. Read side to decide what happened to your position; read takerSide only to know who was the aggressor.
| Payload field | Type | Description |
|---|---|---|
event | String! | Discriminator, table above |
side | String! | BUY or SELL — the receiving account's side of the fill |
failureCode | String | Populated on FAILED only, table below |
execution | OrderExecution! | Snapshot — the OrderExecution base shape, minus failureReason, with the counterparty scoped out as below |
failureCode
| Value | Meaning |
|---|---|
PAYMENT_FAILED | The buyer's charge did not clear — declined, unfunded, or the payment could not be completed |
INVENTORY_UNAVAILABLE | The units could not be claimed at settlement |
SETTLEMENT_ERROR | Anything else |
The webhook carries this closed code rather than free-text detail. Settlement failures are diagnosed from our side; if a fill fails and the code does not tell you enough to act, contact us with the orderNumber.
buyerAccountId and sellerAccountId are not both filled in. You receive your own; the counterparty's arrives as null. On a fill where you bought, buyerAccountId is you and sellerAccountId is null.
buySideOrderId and sellSideOrderId are both still present — they are the join back to the submission event, and an order id belonging to the other party is not readable by you.
The read APIs are the place to look up anything about your own side of a trade; the webhook never carries the other party's identity.
SHIPMENT_EVENT
Physical fulfillment, over the real chain: a shipment group holds the transfers being fulfilled and the labels fulfilling them, and tracking lives on the label.
event | Fires when |
|---|---|
TRACKING_ADDED | A tracking number was attached to a shipment on one of your fills |
TRACKING_REMOVED | A label was voided or replaced — stop watching that tracking number |
STATUS_CHANGE | Carrier movement or a transfer-level transition |
| Payload field | Type | Description |
|---|---|---|
event | String! | Discriminator, table above |
orderExecutionIds | [UUID!]! | Every fill this shipment group fulfills, ascending |
side | String! | BUY receiving or SELL shipping |
transfers | [Transfer!]! | One per fill: id, status, createdAt, updatedAt, ascending by id |
shipmentGroup | ShipmentGroup | id, createdAt, updatedAt — null before a group exists |
label | ShippingLabel | Tracking detail, below — null on transfer-level changes |
The shipment group is the unit, not the fill
Fills bound for the same buyer, address, and asset configuration are consolidated into one shipment group and ship together. A box therefore has no single owning fill, and the payload does not pretend otherwise — orderExecutionIds and transfers describe the whole group.
Both directions are one-to-many, and they compose:
- Many labels per group. A multi-box shipment emits one event per label, each carrying its own
trackingCodeandcurrentStatus. Track each box separately. - Many fills per group. Every event lists all of them. Three fills in four boxes is four events, each naming the same three
orderExecutionIds— not twelve.
Apply a label's status to every id in orderExecutionIds. Reading orderExecutionIds[0] as "the" fill silently drops the rest. Both arrays are sorted and stable, so a redelivery reproduces the payload exactly.
ShippingLabel
| Field | Type | Description |
|---|---|---|
id | UUID! | Label id |
trackingCode | String | Carrier tracking number |
carrier | String | Shipping carrier, e.g. UPSDAP, USPS |
currentStatus | String! | PRE_TRANSIT, IN_TRANSIT, OUT_FOR_DELIVERY, DELIVERED, AVAILABLE_FOR_PICKUP, RETURN_TO_SENDER, FAILURE, UNKNOWN |
estimatedDeliveryDate | DateTime | Carrier estimate |
latestScanMessage | String | Most recent carrier scan message |
latestScanLocation | String | Most recent scan location, e.g. Dallas, TX |
createdAt | DateTime! | Label creation |
updatedAt | DateTime! | Last movement |
Subscribing
A subscription is one destination plus a filter for what it receives. An account can hold any number of them — each retried independently, each with its own health, so a backend endpoint and a Discord channel never affect one another.
Discover the subscribable vocabulary first; it is public and always current:
query {
webhookCapabilities {
capabilities { eventType events }
}
}
Filter entries are either a bare event type (ORDER_SUBMISSION_EVENT — everything in that family) or dot-scoped to one discriminator (ORDER_SUBMISSION_EVENT.FILL). An empty filter receives every event. Unknown entries are rejected at write time, so a typo fails your mutation instead of silently never matching.
mutation {
createWebhookSubscription(input: {
channel: HTTP
config: { url: "https://api.example.com/tradepost-webhooks" }
filters: { eventTypes: ["ORDER_SUBMISSION_EVENT", "SHIPMENT_EVENT.TRACKING_ADDED"] }
}) {
subscription {
id
config { url secret }
filters { eventTypes }
}
error { code message }
}
}
The signing secret (whsec_...) is generated server-side — it never accepts a supplied value — and is returned here and on any later read by the owning account. HTTP destinations must be https. One active subscription per destination per account; a duplicate returns WEBHOOK_DUPLICATE_DESTINATION.
For a DISCORD subscription pass config: { webhookUrl: "https://discord.com/api/webhooks/..." } instead. The Discord URL embeds its own token, so there is no separate secret and no signature — the event renders as an embed in the channel.
Verifying signatures
Every HTTP delivery carries one header:
Tradepost-Signature: t=1717012345,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
t is the unix timestamp (seconds) at signing; v1 is an HMAC-SHA256 hex digest of {t}.{body} keyed with your subscription secret. Verify by reconstructing the same digest and comparing with a timing-safe function. Reject deliveries whose t is older than five minutes — that bounds replay of a captured request.
During the twenty-four hours after a secret rotation, deliveries carry two v1 entries — one per secret. Accept the delivery if any v1 matches.
Compute the digest over the raw request body bytes, never a parsed-and-re-serialized object. Reformatting changes whitespace and key order, and the digest with it.
Node.js:
const crypto = require("crypto");
function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i), p.slice(i + 1)];
})
);
// header may carry multiple v1 entries during rotation
const signatures = signatureHeader
.split(",")
.filter((p) => p.startsWith("v1="))
.map((p) => p.slice(3));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return signatures.some((sig) => {
const a = Buffer.from(expected);
const b = Buffer.from(sig);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
Python:
import hashlib
import hmac
import time
def verify_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool:
timestamp = None
signatures = []
for part in signature_header.split(","):
key, _, value = part.partition("=")
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if timestamp is None or abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in signatures)
Validate your implementation before any order exists: sendTestWebhookEvent sends a canned envelope with a real signature through the real transport, synchronously, and returns what your endpoint answered. Test snapshots carry TRADEPOST_TEST_EVENT markers and random ids; nothing is persisted or retried.
mutation {
sendTestWebhookEvent(input: {
subscriptionId: "..."
eventType: "ORDER_SUBMISSION_EVENT.FILL"
}) {
result { success statusCode latencyMs error }
error { code message }
}
}
Delivery and retries
Respond with any 2xx within ten seconds. Acknowledge first, process async — dedupe on eventId, then hand the envelope to your queue. A slow handler that answers late is the most common cause of duplicate processing: the delivery times out, retries, and your handler runs twice.
A failed delivery (non-2xx, timeout, connection error) retries on a front-loaded schedule — thirty seconds after the first failure, stretching to daily — for roughly three days over ten attempts, then the delivery is marked EXHAUSTED. Each delivery retries independently; one dead destination never delays another. Sustained failure eventually flips the subscription to AUTO_DISABLED — fix the destination, set the status back to ACTIVE with updateWebhookSubscription, and use redeliverWebhookEvent to backfill anything missed.
Deliveries are observable per subscription — every attempt records the status code, latency, and error it saw:
query {
getWebhookDeliveries(input: { subscriptionId: "..." }) {
deliveries {
webhookEventId eventType event status
attemptCount lastStatusCode lastError lastLatencyMs
nextRetryAt deliveredAt
}
error { code message }
}
}
getWebhookEvents lists your account's event log — every envelope that matched a subscription, exactly as delivered — and redeliverWebhookEvent(input: { eventId: ... }) replays one: the same eventId on the wire with a fresh signature, so your dedupe treats it correctly as a redelivery. Replay also backfills subscriptions registered after the event fired.
Managing subscriptions
| Operation | Notes |
|---|---|
getWebhookSubscriptions | Your subscriptions with config and filters; secrets included for the owning account |
updateWebhookSubscription | Change destination, filters, or status (ACTIVE/DISABLED). Secret fields are server-owned and survive updates untouched |
rotateWebhookSubscriptionSecret | New secret immediately; the old one keeps verifying for twenty-four hours (dual v1 entries) |
deleteWebhookSubscription | Soft delete — delivery history stays queryable |