Trade session states
The most commonly misread part of this flow
Seller B does not wait for Seller A to pay, and vice versa — and this extends all the way
through fulfillment, not just checkout. Each trader checks out with the store of the items they
receive, so a trade has two checkouts: side A's (at A's store, paid by B's trader) and side B's (at
B's store, paid by A's trader). As soon as a trade session reaches RESERVED, both checkouts are
open, to be paid in either order, independently. As soon as one is paid, that store ships those
items straight away. Don't build any code path that gates one checkout or fulfillment on the
other's status.
Diagram
DRAFT → PENDING → OFFERED → ACCEPTED → RESERVING → RESERVED
|
+------------+------------+
v v
WAITING_CHECKOUT_A WAITING_CHECKOUT_B ← parallel, independent
| |
v v
CHECKOUT_A_COMPLETE CHECKOUT_B_COMPLETE
| |
fulfillment starts/completes independently per side
| |
+------------+------------+
v
both sides report fulfillment complete
|
v
COMPLETED ← automatic, no manual step
Failure / side states, reachable from most steps above: CANCELLED, EXPIRED, PAYMENT_FAILED,
RESERVATION_FAILED, FULFILLMENT_FAILED, RETURNED, REFUNDED, CLOSED_UNRECIPROCATED (see
the holding policy below).
The holding policy
Two timers govern a merchant-checkout trade session, and only one is ever active at a time:
-
Pre-payment hold — 7 days, from
RESERVED. If neither side has completed checkout within 7 days, the whole session is cancelled automatically. No consequence to either party — nothing was ever paid, nothing to unwind. -
Counterparty hold — 14 days by default, from the first checkout paid; a merchant can set anything from 7 to 14 days (
counterpartyHoldDaysonPATCH /v1/merchants/{id}). The moment ONE checkout is paid, that store ships to the trader who paid, unaffected by anything below — the hold is a grace period for the other checkout, still unpaid. Its length is fixed when the hold starts, from the setting of the store whose checkout is still unpaid, so changing the setting later only affects holds that start afterward. Meanwhile:- The trader who hasn't paid gets a weekly reminder email showing how long they have left.
- They may still pay at any point — that store then ships, and the hold clears.
- Neither trader may simply cancel anymore — once one checkout is paid, closing the trade requires waiting out the hold.
- After the hold elapses, either trader may close the trade via
POST /v1/trade-sessions/:id/close-unreciprocated, with a required note — this releases the unpaid checkout's items back to their seller; the paid checkout stands and is never touched. - The store whose checkout is unpaid can do the same once the hold elapses, via
POST /v1/trade-sessions/:id/excuse-fulfillment. - Some merchants opt into closing this automatically once the hold elapses.
Traders see and explicitly agree to the trade quote — what each of them pays, at which store — before an offer is sent or accepted, and to the applicable hold terms — the fixed 7-day window plus the real counterparty-hold length for their specific trade — before completing checkout.
A side whose merchant runs matching only settles on that merchant's own platform and has 14 days to report the outcome instead — a trade with such a side isn't subject to the 7-day pre-payment hold, and that side gets no counterparty hold of its own. See the matching-only lifecycle.
This whole policy applies only to merchant-checkout trades. A peer-to-peer trade (both sides individual, no merchant) has no checkout/payment concept at all — see "Peer-to-peer path" below.
Transition table
| From | To | Trigger | Side effect |
|---|---|---|---|
| — | DRAFT | Seller starts composing an offer | none external |
DRAFT | PENDING | Offer submitted | offer.created event |
PENDING | OFFERED | Offer delivered/visible to the target seller | offer.created webhook |
OFFERED | ACCEPTED | Target seller accepts | offer.accepted event + webhook; trade session created |
OFFERED | EXPIRED | Default timeout with no response | expiry event |
ACCEPTED | RESERVING | Both sides' items begin locking | reservation.created events |
RESERVING | RESERVED | All listings on both sides locked | reservation.created webhook per side |
RESERVING | RESERVATION_FAILED | Any listing became unavailable mid-lock | session cancelled, no charges attempted |
RESERVED | WAITING_CHECKOUT_A / WAITING_CHECKOUT_B | Immediately, both in parallel | checkout.started webhook to each side's store |
RESERVED/WAITING_* | CANCELLED | Pre-payment hold expires unpaid, or either party cancels | reservations released; no consequence to anyone |
WAITING_CHECKOUT_A | CHECKOUT_A_COMPLETE | A's store reports payment.completed for side A | A's store ships to B's trader immediately |
WAITING_CHECKOUT_B | CHECKOUT_B_COMPLETE | Merchant reports payment.completed for side B | mirror of the row above |
WAITING_CHECKOUT_* | PAYMENT_FAILED | Merchant reports payment.failed, or default timeout | recoverable failure state, not silently retried |
| (per side, independent) | fulfillment started/completed | That side's own fulfillment webhooks | fulfillment.started/fulfillment.completed, carrying side |
| both sides fulfilled | COMPLETED | Both sides report fulfillment complete | terminal state, automatic |
| counterparty hold open | CLOSED_UNRECIPROCATED | Either trader or the unpaid store closes, or auto-closes | only the unpaid checkout's items released; session can never be resumed |
post-COMPLETED | RETURNED / REFUNDED | Merchant-reported | informational only |
Multi-item trades
A session's A side and B side are each an array of listings, not a single listing — many-to-many
is supported from ACCEPTED onward. A side is not RESERVED until every listing in its set is
locked; a single unavailable listing fails the whole side's reservation, not a partial trade.
Peer-to-peer path
A peer-to-peer trade (every listing on both sides has no merchant) never uses
WAITING_CHECKOUT_* at all. It goes RESERVED → a scheduling state directly, then either party
confirms via POST /v1/trade-sessions/:id/scheduling/confirm or reports a failed exchange via
.../scheduling/fail. None of the holding-policy behavior above applies to this path.
