Skip to main content

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.

FieldTypeDescription
versionString!Protocol version — "2"
eventIdUUID!Unique per event; identical across redeliveries. Dedupe on this
eventTypeString!ORDER_SUBMISSION_EVENT, ORDER_EXECUTION_EVENT, or SHIPMENT_EVENT
occurredAtDateTime!When the underlying transition happened — not when this delivery was attempted
accountIdUUID!The account this delivery is for
payloadObject!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:

eventFires when
RESTINGThe order is live on the book
FILLA resting order accrued a fill — cumulative progress in filledQuantity / remainingQuantity
AMENDEDOrder terms changed — yours, or a market configuration reprice
PARTIALLY_FILLEDSome quantity delivered while the remainder still rests. Not terminal
FILLEDFully filled and settled
CANCELLEDThe 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
EXPIREDTime 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 fieldTypeDescription
eventString!Discriminator, table above
reasonStringWhy the transition fired, e.g. USER_CANCELLED, BULK_CANCELLED, FOK_UNFILLABLE, REJECTED, MAKER_FILL, SETTLEMENT_ROLLUP
errorCodeStringPopulated when reason is REJECTED
filledQuantityIntCumulative units matched. Null on events that carry no fill information
remainingQuantityIntqty minus filledQuantity; null whenever filledQuantity is null
settledQuantityIntCumulative units delivered — settlement events only. Trails filledQuantity while a leg is in flight, and stays behind it when a leg failed
submissionOrderSubmission!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.

warning

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.

eventFires whenTerminal
CREATEDThe cross happened — the fill notificationno
DISPUTEDThe buyer raised a dispute; settlement is frozen until it resolvesno
DISPUTE_RESOLVEDThe dispute closed and the fill went back to settlingno
SETTLEDMoney and inventory movement completedyes, but see below
FAILEDSettlement failed — failureCode says which kindyes
REVERSEDUnwound after settlementyes

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 word

A 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:

FieldWhereAnswers
sidepayloadWhich leg you were on this fill
takerSideinside executionWhich 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 fieldTypeDescription
eventString!Discriminator, table above
sideString!BUY or SELL — the receiving account's side of the fill
failureCodeStringPopulated on FAILED only, table below
executionOrderExecution!Snapshot — the OrderExecution base shape, minus failureReason, with the counterparty scoped out as below

failureCode

ValueMeaning
PAYMENT_FAILEDThe buyer's charge did not clear — declined, unfunded, or the payment could not be completed
INVENTORY_UNAVAILABLEThe units could not be claimed at settlement
SETTLEMENT_ERRORAnything 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.

Only your own account id is populated

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.

eventFires when
TRACKING_ADDEDA tracking number was attached to a shipment on one of your fills
TRACKING_REMOVEDA label was voided or replaced — stop watching that tracking number
STATUS_CHANGECarrier movement or a transfer-level transition
Payload fieldTypeDescription
eventString!Discriminator, table above
orderExecutionIds[UUID!]!Every fill this shipment group fulfills, ascending
sideString!BUY receiving or SELL shipping
transfers[Transfer!]!One per fill: id, status, createdAt, updatedAt, ascending by id
shipmentGroupShipmentGroupid, createdAt, updatedAt — null before a group exists
labelShippingLabelTracking 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 trackingCode and currentStatus. 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

FieldTypeDescription
idUUID!Label id
trackingCodeStringCarrier tracking number
carrierStringShipping carrier, e.g. UPSDAP, USPS
currentStatusString!PRE_TRANSIT, IN_TRANSIT, OUT_FOR_DELIVERY, DELIVERED, AVAILABLE_FOR_PICKUP, RETURN_TO_SENDER, FAILURE, UNKNOWN
estimatedDeliveryDateDateTimeCarrier estimate
latestScanMessageStringMost recent carrier scan message
latestScanLocationStringMost recent scan location, e.g. Dallas, TX
createdAtDateTime!Label creation
updatedAtDateTime!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.

Verify the raw body

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

OperationNotes
getWebhookSubscriptionsYour subscriptions with config and filters; secrets included for the owning account
updateWebhookSubscriptionChange destination, filters, or status (ACTIVE/DISABLED). Secret fields are server-owned and survive updates untouched
rotateWebhookSubscriptionSecretNew secret immediately; the old one keeps verifying for twenty-four hours (dual v1 entries)
deleteWebhookSubscriptionSoft delete — delivery history stays queryable