Home / Platform

Architecture & API

A custodian that also runs a market has to be right about two things simultaneously: where every physical object is, and who owns it. This is how those two facts are kept in agreement.

1. System shape

Four services, one source of truth. The ledger is authoritative for ownership and cash; the warehouse system is authoritative for physical location. Nothing else is allowed to have an opinion about either.

Components
┌─────────────┐   orders, swaps    ┌──────────────────┐
│  clients    │ ─────────────────► │  gateway         │  auth, idempotency,
│  web / API  │ ◄───────────────── │                  │  rate limits
└─────────────┘   book, fills      └────────┬─────────┘
                                            │
                        ┌───────────────────┼───────────────────┐
                        ▼                   ▼                   ▼
                ┌───────────────┐  ┌─────────────────┐  ┌──────────────┐
                │ matching      │  │ ledger          │  │ custody      │
                │ engine        │─►│ double entry    │◄─│ WMS bridge   │
                │ price/time    │  │ hash chained    │  │ bins, picks  │
                └───────────────┘  └────────┬────────┘  └──────────────┘
                                            │ outbox
                                            ▼
                                   ┌─────────────────┐
                                   │ webhooks, tape, │
                                   │ market data     │
                                   └─────────────────┘

authoritative for ownership + cash ......... ledger
authoritative for physical location ........ WMS
authoritative for nothing ................... everything else

Fills and swaps are written to the ledger inside the same transaction that mutates the order book, and downstream consumers — the tape, webhooks, market data — read from an outbox table written in that same transaction. There is no path where a webhook fires for a trade that did not commit, because the webhook row and the journal row land together or not at all.

2. The ledger

Ownership is not a column on an item. It is a balance derived from a journal. That sounds like extra work until the first time someone asks you to prove who owned a $400,000 card on a particular Tuesday.

Every entry is a set of lines. The rules are the ordinary ones:

  • Debit increases the balance the account holder controls; credit decreases it.
  • Each instrument balances independently. Asset units net to zero, dollars net to zero, within the same entry.
  • Assets are counted in units, cash in integer cents. No floating point touches a balance, ever.
  • Entries are append-only. A correction is a new reversing entry, never an edit.
Account namespaces
holder:<user_id>              // what a user owns, in ASSET:<item_id> units
cash:<user_id>                // a user's USD balance
revenue:fees                  // maker, taker, swap, storage, ship-out
equity:opening                // account funding
external:owner_possession     // where an item goes when custody ends

That last namespace is the one people forget. When a card is delivered to your door it leaves custody but must not leave the ledger, or the asset simply evaporates and nothing balances. Instead the holder account is credited and an external possession account is debited — the item's history stays complete, and the books still close.

A fill, in full
type   : trade.fill
memo   : Fill · Charizard — PSA 10 @ $429,450.00

account                instrument             debit        credit
────────────────────────────────────────────────────────────────────
holder:usr_4f2a17      ASSET:itm_c8a1f9       1 unit
holder:usr_9b71c4      ASSET:itm_c8a1f9                    1 unit
cash:usr_9b71c4        USD                    $429,450.00
cash:usr_4f2a17        USD                                 $429,450.00
revenue:fees           USD                    $25,767.00
cash:usr_4f2a17        USD                                 $17,178.00   // taker 4%
cash:usr_9b71c4        USD                                  $8,589.00   // maker 2%
────────────────────────────────────────────────────────────────────
ASSET:itm_c8a1f9 → 0     USD → 0     ✓ balanced

3. Hash-chained audit trail

Balancing proves the entry is internally coherent. It does not prove nobody went back and changed it. For that, each entry commits to the one before it.

Chaining
hash[n] = SHA-256( hash[n-1] ‖ canonical_json(entry[n]) )

canonical_json:  keys sorted recursively, so two structurally
                 equal entries always produce the same digest

Change one cent in entry 40 and its digest changes; entry 41 committed to the old digest, so it no longer verifies, and neither does anything after it. Rewriting history means rewriting every subsequent entry — which is detectable by anyone holding an older head.

Two chains run in parallel: the journal for value movement, and the event stream for custody and operations — dock scans, imaging with its measurements, cert lookups, bin assignments, picks, environmental excursions. The terminal's Ledger tab has a Verify button that walks both chains and recomputes every digest in the browser.

What the chain proves, and what it does not. It proves a record has not been altered since it was written. It says nothing about whether the record was true when written. A flawless chain of custody rooted in a fraudulent deposit is a flawless chain of custody rooted in a fraudulent deposit — under UCC §2-403 a transferee gets the title the transferor had power to transfer, and a thief has none to give. The strength of a custodial system is set at intake, not at settlement. The full analysis.

Why not just a database audit table. An audit table is written by the same system that could be compromised, and a sufficiently motivated writer can rewrite it consistently. A hash chain does not stop tampering either — but it makes tampering detectable without trusting the operator, which is a meaningfully different property for a custodian to be able to offer.

4. The matching engine

Continuous limit order book, price–time priority, one book per SKU. The incoming order is the taker; resting orders are makers and set the execution price.

Match loop
while (taker.unfilled > 0):
    candidates = resting orders where
        sku      == taker.sku
        side     != taker.side
        user     != taker.user           // no self-crossing
        price crosses taker's limit

    if candidates is empty: break

    sort by (best price, then oldest timestamp)
    maker = candidates[0]
    qty   = min(taker.unfilled, maker.unfilled)
    price = maker.price                  // resting order sets the price

    for each unit in qty:
        if taker is an unreserved market buy
           and available_cash < price + taker_fee:
               break                     // stop cleanly; keep the fills already made
        settle(buy, sell, price)

Settlement picks a specific physical item from the ask's reserved set, moves title, moves cash, charges both fee sides, writes one balanced journal entry, updates the tape, and emits a custody event recording that no physical movement occurred. Then the invariant check runs.

Reservations

A resting bid reserves cash up front, computed at the limit price including the worst-case taker fee. If it later fills at a better price, the difference is released on the spot.

Bid obligation
obligation = (price × qty) + round(price × qty × taker_bps / 10000)

// Per-unit release on a fill is computed from the limit price, so integer
// rounding can leave a few cents behind on the final unit. A fully filled
// order therefore releases any remainder explicitly — otherwise the
// reservation total drifts away from the book, and the invariant check
// will (correctly) start rejecting unrelated transactions.

5. Invariants and transactions

Every command is a transaction: snapshot, apply, assert, commit — or roll back completely. The assertions are cheap and they run every single time, because an invariant you only check nightly is a bug you discover in the morning.

InvariantWhat it catches
No negative cashFee arithmetic that overdraws an account
Reserved ≤ balanceDouble-committing the same funds to two orders
Reserved cash == sum of open obligationsReservation drift — the single most likely source of phantom buying power
Every open ask references an item its seller still holdsAn item sold or withdrawn out from under a resting order
Every ask's item is reserved to that exact orderThe same slab committed to two sell orders
Every journal entry balances per instrumentAny value-movement bug, generically

Failure is byte-identical rollback. Try to buy a $320,000 Black Lotus with $25,000 of buying power in the terminal. You get a typed error, and the entire state — orders, reservations, journal, custody — is exactly what it was before you clicked. There is no partial application to clean up later.

6. Data model

Core tables
CREATE TABLE printing (
  id             text PRIMARY KEY,
  game           text NOT NULL,          -- pokemon | yugioh | mtg
  set_code       text NOT NULL,
  number         text NOT NULL,          -- '4/102', 'LOB-001', '0348/0500'
  language       text NOT NULL,
  finish         text NOT NULL,          -- holofoil | textured-foil | non-foil …
  edition        text NOT NULL,          -- 1st Edition | Shadowless | Unlimited
  rarity         text NOT NULL,
  year           int,
  artist         text,
  UNIQUE (game, set_code, number, language, finish, edition)
);

CREATE TABLE sku (
  id             text PRIMARY KEY,        -- printing|grader|grade
  printing_id    text REFERENCES printing,
  grader         text NOT NULL,          -- PSA | BGS | CGC | SGC | TAG | RAW
  grade          numeric(3,1),
  sub_centering  numeric(3,1),          -- BGS/CGC/TAG only
  sub_corners    numeric(3,1),
  sub_edges      numeric(3,1),
  sub_surface    numeric(3,1),
  pop_at_grade   int,
  pop_above      int,
  pop_synced_at  timestamptz,
  UNIQUE (printing_id, grader, grade)
);

CREATE TABLE item (
  id             text PRIMARY KEY,
  sku_id         text REFERENCES sku,
  owner_id       text REFERENCES account,  -- the only field a trade changes
  cert_number    text,
  lpn            text NOT NULL UNIQUE,     -- permanent, survives ownership changes
  facility       text NOT NULL,           -- WEST-01 | MID-01
  bin            text NOT NULL,           -- 'D14-R3-S07'
  custody        text NOT NULL,           -- state machine, see how-it-works
  reserved_by    text,                  -- order | swap | withdrawal holding this item
  acquired_cents bigint,
  measurements   jsonb                  -- weight, thickness, centering, UV, gloss
);

CREATE TABLE journal_entry (
  seq            bigserial PRIMARY KEY,
  id             text NOT NULL UNIQUE,
  ts             timestamptz NOT NULL,
  type           text NOT NULL,
  memo           text,
  meta           jsonb,
  prev_hash      char(64) NOT NULL,
  hash           char(64) NOT NULL
);

CREATE TABLE journal_line (
  entry_seq      bigint REFERENCES journal_entry,
  account        text NOT NULL,
  instrument     text NOT NULL,          -- 'USD' | 'ASSET:<item_id>'
  debit          bigint,                -- cents, or units for assets
  credit         bigint,
  CHECK ((debit IS NULL) != (credit IS NULL))
);

Two details worth calling out. The UNIQUE on the printing tuple is what stops a Shadowless Charizard and an Unlimited one from collapsing into the same row. And reserved_by on the item is what makes "committed" a single, checkable fact rather than something reconstructed by scanning three other tables.

7. Marks and price discovery

Portfolios have to be marked to something, and the honest answer is often "we don't know." Our order of preference:

  1. Last traded price on this SKU. An actual print between two consenting parties on this exact instrument.
  2. Mid of the current book, when the spread is tight enough to be informative.
  3. Reference price, seeded from observed comparable sales, when the book is one-sided or empty.

What we deliberately do not do is interpolate across graders or across grades. A PSA 9 print does not tell you what the PSA 10 is worth beyond a very loose bound, and a BGS 9.5 print tells you about the BGS 9.5 book. Thin markets are thin; smoothing the number does not make the liquidity appear, it just hides the fact that it is missing.

Population is a supply signal, not a price. We surface pop pressure — the share of the graded pool above your grade — next to the mark rather than folding it into the mark. A model that silently bakes population into price is making a judgement the user cannot see or disagree with. See pop pressure.

8. REST API

Everything the dashboard does, the API does. Bearer tokens, JSON in and out, integer cents throughout. Mutations take an Idempotency-Key header and replay the original response for 24 hours — a retried order is never a duplicate order.

Base https://api.tcgkeepr.com/v1 600 req/min read 60 req/min write Idempotency-Key required on POST

Instruments

GET /v1/skus/:id
{
  "id": "pkm-base1-004-1ed|PSA|10",
  "printing": {
    "game": "pokemon", "set_code": "BS", "number": "4/102",
    "name": "Charizard", "language": "EN",
    "finish": "holofoil", "edition": "1st Edition",
    "rarity": { "native": "Rare Holo", "tier": 4 },
    "year": 1999, "artist": "Mitsuhiro Arita"
  },
  "grade": { "grader": "PSA", "value": 10, "label": "Gem Mint" },
  "population": { "at_grade": 121, "above": 0, "pressure": 0.0,
                   "synced_at": "2026-08-02T06:00:00Z" },
  "quote": { "bid": 41055000, "ask": 42945000,
              "last": 42000000, "spread_bps": 450 }
}
GET /v1/skus/:id/book?depth=10
{
  "bids": [ { "price": 41055000, "qty": 2, "orders": 2 },
             { "price": 40020000, "qty": 1, "orders": 1 } ],
  "asks": [ { "price": 42945000, "qty": 2, "orders": 1 },
             { "price": 43980000, "qty": 1, "orders": 1 } ]
}

Orders

POST /v1/orders
// a bid — quantity, because any copy at this grade is deliverable
{ "sku": "pkm-base1-004-1ed|PSA|10",
  "side": "buy", "type": "limit",
  "price_cents": 41500000, "qty": 1, "tif": "GTC" }

// an ask — specific items, because you sell a slab, not an abstraction
{ "sku": "pkm-base1-004-1ed|PSA|10",
  "side": "sell", "type": "limit",
  "price_cents": 42900000,
  "item_ids": ["itm_c8a1f9"] }

// 201 response
{ "id": "ord_00019f", "status": "open", "filled": 0,
  "reserved_cents": 43160000,        // notional + worst-case taker fee
  "fills": [] }
DELETE /v1/orders/:id
{ "id": "ord_00019f", "status": "cancelled",
  "released_cents": 43160000,
  "released_items": [] }

Swaps

POST /v1/swaps
{ "counterparty": "usr_9b71c4",
  "offered":      ["itm_c8a1f9"],
  "requested":    ["itm_2de503"],
  "boot_cents":   18000,          // positive: you add cash
  "expires_in":   86400,
  "note":         "Straight across plus cash to bridge the gap." }
POST /v1/swaps/:id/accept
{ "id": "swp_000021", "status": "settled",
  "journal_id": "jnl_0004a2",
  "fee_cents": 9300,
  "physical_movement": false,
  "legs": [
    { "item": "itm_c8a1f9", "to": "usr_9b71c4",
      "facility": "WEST-01", "bin": "A35-R3-S05" },
    { "item": "itm_2de503", "to": "usr_4f2a17",
      "facility": "MID-01", "bin": "E20-R3-S12" } ] }

Note the two facilities in that response. Those cards are most of a continent apart and the swap still settled in one transaction, because neither of them went anywhere.

Custody

GET /v1/items/:id/history
{ "item": "itm_c8a1f9", "lpn": "LPN7761A8BE63",
  "events": [
    { "seq": 1104, "kind": "intake.manifested",   "actor": "usr_4f2a17" },
    { "seq": 1105, "kind": "custody.received",    "actor": "dock:WEST-01" },
    { "seq": 1106, "kind": "custody.imaging",     "actor": "imaging:WEST-01",
      "data": { "dpi": 1200,
                "captures": ["front","back","raking_15deg","uv_365nm"],
                "weight_g": 28.27, "thickness_mm": 6.06 } },
    { "seq": 1107, "kind": "custody.authenticating", "actor": "auth:WEST-01",
      "data": { "cert_lookup": "52820658", "pop_at_sync": 121,
                "holder_integrity": "weld seam nominal, label font match" } },
    { "seq": 1108, "kind": "custody.vaulted",     "actor": "vault:WEST-01",
      "data": { "bin": "A35-R3-S05" } },
    { "seq": 1147, "kind": "custody.title_transfer", "actor": "exchange",
      "data": { "to": "usr_9b71c4", "physical_movement": false } }
  ] }
POST /v1/withdrawals
{ "item_ids": ["itm_c8a1f9", "itm_71b0de"] }

// 201
{ "id": "wdr_000007", "status": "requested",
  "parcels": 2, "facilities": ["WEST-01", "MID-01"],
  "quote": { "shipping_cents": 2199, "coverage_cents": 27690,
              "declared_cents": 4360000, "total_cents": 29889 },
  "packaging": "Slab sleeve → bubble wrap → rigid insert → double-boxed with void fill" }

Errors

Typed, actionable, and never partially applied.

CodeHTTPMeaning
INSUFFICIENT_FUNDS402Buying power does not cover notional plus fee
NOT_VAULTED409Item is not in the vaulted state — it is in transit, in QA, or already withdrawn
ALREADY_RESERVED409Item is committed to another order, swap or ship-out
LEG_UNAVAILABLE409A swap leg moved between offer and acceptance
SKU_MISMATCH422An item was offered against the wrong instrument
UNBALANCED500Journal entry failed to balance — the transaction was rejected, never written

9. Webhooks

Signed with HMAC-SHA256 over the raw body, timestamped to prevent replay, retried with exponential backoff for 24 hours. Delivery is at-least-once — handlers must be idempotent on the event id.

EventFires when
order.filledAny fill, partial or complete
order.cancelledCancel, IOC expiry, or market remainder expiry
swap.receivedSomeone offers you a swap
swap.settledA swap commits
custody.state_changedAny custody transition on an item you hold
intake.quarantinedAuthentication failed — needs your decision
withdrawal.updatedParcel advances a stage; carries tracking once manifested
Verifying a webhook
signature = HMAC_SHA256(secret, timestamp ‖ "." ‖ raw_body)

// Compare in constant time. Reject if |now − timestamp| > 300s.
// Sign the RAW body — re-serialising JSON before verifying will
// eventually reorder a key and break in production at 3am.

The engine on this page is the engine in the terminal

The matching loop, the reservations, the invariant checks and the SHA-256 chain described here are implemented in assets/js/engine.js and run in your browser. Open the console and poke at it.