v1.0.0
OpenAPI 3.1.1

Phygitals Partner API

The Phygitals Partner API lets you embed Phygitals' pack, buyback, and physical-shipping flows into your own platform: browse and buy packs, look up the items inside them, sell items back to the pool, and quote, request, and track physical shipments. Everything is REST over HTTPS with JSON request and response bodies. The operations are grouped in the sidebar — Storefront (read + buy) and Events (outbound webhooks).

Platform

The infrastructure for digital collectibles. Sell digital packs backed by real, graded physical cards. An end user buys a pack, watches it reveal instantly, holds the card in an insured vault, and then ships it or sells it back whenever they want — all through this one API. These are the same rails the main Phygitals storefront runs on: $350M+ GMV, 2.8M+ transactions, 100K+ users, and a pack-configured buyback (often ~85–90% of FMV — see each pack's buyback_percent).

The four-step flow

  1. Buy a pack — debited from your prepaid partner ledger (not end-user card/crypto on this API). Each pack is backed by real physical cards. Start with POST /vm/buy/init; look it up with POST /vm/buy/status (or the purchase.settled webhook).
  2. Instant reveal — the picked cards are known the moment the pack opens. buy/init returns them synchronously (there is no pending state to poll for).
  3. Securely vaulted — cards sit in top-tier insured US facilities; the user owns the digital representation until they act on it. Holdings are surfaced by GET /inventory/{user_id}.
  4. Ship or sell — redeem for worldwide physical shipping (POST /ship/quotePOST /ship/requestGET /ship/order/{order_id}), or sell back at the pack's buyback_percent of FMV via POST /vm/buyback.

Vault, pricing & logistics

Every digital collectible is 1:1 backed by a real, graded card in an insured US vault. Phygitals operates the full physical stack end-to-end — you don't store or fulfill anything yourself.

Vault partners Alt (primary), PSA, Fanatics
Pricing Alt's live FMV feed powers buyback_price on every item and every sellback
Insurance Full coverage across all stored items, climate-controlled facilities
Fulfillment Worldwide shipping, tracking and insurance included on every redemption

Phygitals funds the buybacks. Sellback liquidity is underwritten by Phygitals, not the partner. When a user calls POST /vm/buyback, the credit hits your partner ledger — you don't post capital, run a secondary market, or carry sellback risk.

White-label. Phygitals is both a direct-to-consumer platform and infrastructure for partner brands. A launch can be API-only (you keep your own frontend and integrate these endpoints) or a fully managed build (Phygitals designs the branded storefront and backend on the same rails). Every partner gets a dedicated point of contact. Partnership inquiries: hello@phygitals.com.

Concepts

  • Pack — a themed, purchasable bundle (the id the claw_ids filter references). Buying one draws items from it provably-fair and returns them.
  • Item — an individual card / NFT pulled from a pack. You can look one up, ship it, or sell it back to the pool.
  • Chase card — one of a pack's headline high-value cards, its "top hits": the marquee, long-odds pulls a pack can yield (a numbered rookie autograph, a low-population parallel, and the like). GET /vm/chase/{slug} returns them, so you can render a "what could I pull?" preview before a buyer purchases. They're the best-case outcomes in the pack's pool, not a guarantee that any given buy lands one. (GET /vm/available always returns chase: [] — use the chase endpoint for top hits.)
  • Buyback — selling an item back to the pool at the pack's buyback_percent of FMV for instant liquidity, via POST /vm/buyback. Phygitals funds it; the partner posts no capital.
  • Vault — the insured US facility (Alt / PSA / Fanatics) holding the physical card behind each item until the user ships or sells it.
  • Partner ledger — prepaid balance Phygitals debits on buy/ship and credits on buyback. Live partner settlement is ledger-based, not an on-chain payment from the end user.
  • Seed commitment — a server seed we bind ourselves to before a draw, via POST /vm/buy/commit, which returns its hash and a single-use commitment_id to spend on the buy. It turns a purchase's fairness proof from a record of the draw into a pre-commitment — see Provable fairness.

Getting an API key

Access is provisioned per partner: you need a Partner account on Phygitals before you can call the API. Once your organization is set up, an owner of your partner mints keys from the partner dashboard (the "Docs & setup" panel):

  • Sandbox keys (pk_sandbox_…) — a partner owner can create these self-serve. Start here.
  • Live keys (pk_live_…) — issued by Phygitals only. A partner owner cannot self-issue one; contact your Phygitals partner manager (or support) to enable production access.

The secret is shown exactly once, at creation — store it somewhere safe. If you lose it, revoke the key and mint a new one. Don't have a partner account yet? Talk to your Phygitals contact — or email hello@phygitals.com — to get onboarded.

Authentication

Every request must be authenticated. Two credentials are accepted, and either one satisfies a request:

  • X-API-Key: <your-key> — your partner API key. This is the primary credential for server-to-server integrations, and the only way to reach live data.
  • Authorization: Bearer <token> — a signed-in partner-dashboard session token for a user belonging to your partner. Session auth is sandbox-only; live access requires a pk_live_… API key.
curl https://api.phygitals.com/api/v1/vm/available \
  -H "X-API-Key: pk_live_xxxxxxxxxxxxxxxxxxxxxxxx"

Base URLs & environments

Production is https://api.phygitals.com/api/v1 — that's the base URL to build against, and the one every example here uses. The raw OpenAPI document behind this reference is served on it too, at https://api.phygitals.com/api/v1/spec.json.

The same operations are mounted on four prefixes; pick the one for the environment you want.

Environment Base URL Settlement
Production (live) https://api.phygitals.com/api/v1 Prepaid partner ledger + real vaulted inventory
Production (long form) https://api.phygitals.com/api/partner/v1 Same as production
Sandbox https://api.phygitals.com/api/partner/sandbox/v1 Fully simulated (no ledger / vault / chain)
Legacy https://api.phygitals.com/_/api/v1 Follows the key's mode

Sandbox runs the full API against a simulated backend — buys, buybacks, and settlement are emulated and nothing touches the vault or chain — so you can build and test end-to-end for free. The sandbox base URL forces sandbox behavior for any credential, so even a live key is safely downgraded there.

The production base URL requires a live key: a sandbox-scoped credential (a pk_sandbox_… key, or a dashboard session) is rejected with 403 rather than being accepted against live data. https://api.phygitals.com/api/partner/v1 is a long-form alias of https://api.phygitals.com/api/v1, kept for integrations that already call it; prefer the short form. The legacy https://api.phygitals.com/_/api/v1 prefix also keeps working and follows the key's own mode, but new integrations should use https://api.phygitals.com/api/v1 (or https://api.phygitals.com/api/partner/sandbox/v1 to start in sandbox).

Conventions

  • Field names are snake_case in every request and response body.
  • Money types vary by field: pack listing prices / admin pack rows use decimal strings (e.g. "25"); item fmv / buyback_price, buy/buyback amount, and ship rate costs are JSON numbers. Never invent floats where the schema uses a string. Timestamps are ISO-8601 / RFC-3339 UTC strings (e.g. "2026-01-15T09:30:00Z").
  • Idempotency is supported on every write bar one (POST /vm/buy/commit) — see the dedicated section below.

Idempotency

Every mutating request (buy, buyback, create-pack, …) can carry an idempotency key so a dropped connection can't double-execute. Send one on every write.

  • Two equivalent channels. Supply the key as an Idempotency-Key: <token> HTTP header or as an idempotency_key field in the JSON body — they are interchangeable. If you send both, the body field wins and the header is ignored.
  • Replay. Retrying with the same key and the same request body replays the original response verbatim, carrying an idempotency-replayed: true response header, instead of executing again. Stored responses stay replayable for 24 hours.
  • Reuse conflict. Reusing a key with a different request body is rejected 422 IDEMPOTENCY_KEY_REUSED — a sign a key was recycled by mistake.
  • In-flight duplicate. The same key retried before the first call has returned is rejected 409 DUPLICATE_REQUEST; wait for the original to finish (or poll the status endpoint) rather than retrying immediately.
  • Commit-reveal buys retry identically. Passing a commitment_id does not change any of the above: a retry under a key that already bought is still 409 DUPLICATE_REQUEST, even though the commitment it names was spent by that same purchase. You never have to special-case the commit-reveal path in your retry logic. 409 BUY_COMMITMENT_ALREADY_USED means something different — that commitment was already consumed by another attempt, yours under another key or whoever else held the id — and is covered under Provable fairness. What DOES change with a commitment_id: retrying a genuinely failed buy needs a fresh one.
  • Failed attempts don't stick. A key whose only prior attempt failed is not blocked: the retry executes fresh, since nothing was charged. A commitment_id is the one thing a failed attempt does consume — retry with a fresh one (see Provable fairness).
  • Looking up a past attempt. POST /vm/buy/status and POST /vm/buyback/status accept the same idempotency_key you sent on the original write to resolve its durable outcome. There the key is a lookup key, not a dedup token, so passing it never trips the reuse conflict above.
  • One exempt write: POST /vm/buy/commit. It ignores the key entirely — none of the above applies, and a retry after a dropped connection really does mint a second commitment (harmless: an unspent commitment reserves nothing and costs nothing; just use the commitment_id from the reply you did receive). It is exempt so that ONE key can cover a whole purchase — the commit and the POST /vm/buy/init that spends it — instead of the buy being rejected 422 IDEMPOTENCY_KEY_REUSED for carrying the same key as the commit. Send your purchase key on the buy; the commit doesn't need one.

Provable fairness

Every live pull is drawn from two seeds — a server seed we hold and a client seed you can supply — and both are published once the buy has settled, so you or your end-user can re-derive the outcome instead of taking our word for it. The endpoints are grouped under the Fairness tag.

There are two modes, and they differ only in when we bind ourselves to the server seed:

  • Record of the draw (the default) — omit commitment_id and the server seed is minted at draw time. The proof shows exactly what ran and is internally consistent, but the seed and its hash were recorded together, so it cannot show we were committed to that seed beforehand.
  • Commit-reveal — call POST /vm/buy/commit first and we hand you sha256(server_seed) before any draw runs, revealing the seed only once the buy it backs has settled. That the revealed seed hashes to a value you were already holding is what rules out a seed chosen to suit the outcome.

Send your own client_seed on the buy in either mode. Without one the platform picks both halves of the draw input, and a commitment then proves only that the hash predated the draw — not that the input was outside our control.

Commit → buy → verify

1. Commit. POST /vm/buy/commit takes no request body and returns commitment_id and server_seed_hash. Show that hash to your end-user, or record it, now — its existing before the buy is the whole point. Commitments are single-use, never expire with a live key, and ignore Idempotency-Key (see Idempotency), so one key can cover both this call and the buy that spends it.

2. Buy. POST /vm/buy/init with that commitment_id and your own client_seed. The draw runs on the seed you already hold the hash of. The buy mints its own session_id and returns it — the commitment id is never reused as a session id.

One commitment, one attempt. The buy consumes the commitment_id before it draws, so it is spent whether or not it settles: if the buy fails — the pack sold out, your balance was short — that commitment is gone and a retry gets 409 BUY_COMMITMENT_ALREADY_USED. Mint a fresh one and buy again; POST /vm/buy/commit is free and takes no body, so a replacement costs you nothing. Guards that don't depend on the seed run first, so ordinary integration mistakes — an unknown or disabled pack, an amount over max_per_mint — reject before the commitment is touched and cost you nothing.

3. Verify. GET /vm/sessions/{session_id}/fairness-proof reveals server_seed. Three checks make it a commit-reveal rather than a record after the fact:

  • sha256(server_seed) reproduces the proof's server_seed_hashand the server_seed_hash step 1 handed you. Comparing against your own stored copy is what the whole flow rests on; comparing only within the response proves nothing beyond internal consistency.
  • committed_at matches when POST /vm/buy/commit answered you. It is when we recorded the seed, which on a committed buy is that call — but every draw records one, so the field's presence alone proves nothing. Your own stored hash is the evidence; this is a cross-check against it, not a substitute.
  • client_seed is the value you sent (when you sent none, it is the session_id we minted).
# 1. commit — returns { commitment_id, server_seed_hash }
curl -X POST https://api.phygitals.com/api/v1/vm/buy/commit \
  -H "X-API-Key: pk_live_xxxxxxxxxxxxxxxxxxxxxxxx"

# 2. buy with it — returns { session_id, nfts }
curl -X POST https://api.phygitals.com/api/v1/vm/buy/init \
  -H "X-API-Key: pk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"id":"<pack_id>","amount":1,"user_id":"<your_user>",
       "commitment_id":"<commitment_id>","client_seed":"<your_seed>"}'

# 3. reveal — sha256(server_seed) must equal the hash from step 1
curl https://api.phygitals.com/api/v1/vm/sessions/<session_id>/fairness-proof \
  -H "X-API-Key: pk_live_xxxxxxxxxxxxxxxxxxxxxxxx"

Then re-derive the picks themselves. Each selections entry records the values both steps of a draw generated — tierRandom for the rarity tier, then rowSeed and itemRandom for the item within it. GET /vm/sessions/{session_id}/fairness-proof documents the exact derivation, including the one step that is not independently reproducible (the item-within-tier pick, which would require the pool of eligible items as it stood at draw time). GET /vm/fairness-proofs pages through every proof you have recorded, newest first, if you would rather reconcile in bulk than per session.

Commitment errors

  • 404 BUY_COMMITMENT_NOT_FOUND — unknown or invalid commitment_id. Treat each id as a secret and mint a fresh one if you no longer have a usable copy.
  • 409 BUY_COMMITMENT_ALREADY_USED — that commitment has already been consumed by a POST /vm/buy/init, and one commitment backs one attempt. Mint a fresh one with POST /vm/buy/commit; retrying this one cannot succeed. The consuming attempt need not have succeeded — see "One commitment, one attempt" above. Note this is not what a plain retry of your own settled buy returns: a repeat under an Idempotency-Key that already bought is 409 DUPLICATE_REQUEST exactly as it would be without a commitment (see Idempotency).

Sandbox simulates draws rather than running the fairness protocol, so it records no proof — see Sandbox.

Sandbox

The sandbox runs the full API against a simulation, so you can integrate and test the whole buy → inventory → sellback → shipping flow without consuming inventory, moving money, or shipping real cards. Point at /api/partner/sandbox/v1 (or use a pk_sandbox_… key). It mirrors the live surface — same routes, same request/response shapes, same error codes — but a few behaviors differ, and you should know them before you rely on them:

  • State is ephemeral. Sandbox sessions, simulated inventory, buybacks, and shipping orders do not persist across restarts or deploys. Treat every sandbox run as fresh.
  • No real fulfillment. Nothing touches the vault (Alt / PSA / Fanatics), the chain, a payment processor, or a shipping carrier — buys, buybacks, settlement, and shipping rates are all simulated. (Destination address validation is the one exception: POST /ship/quote runs the same real address check that live does.)
  • Not a concurrency test bed. Sandbox does not faithfully reproduce live contention when two buyers race for the same card — use live keys when you need to validate that behavior.
  • Idempotency replay works; some live-only duplicate guards do not. Retrying with the same Idempotency-Key and body still replays the first response. Hard rejection of some concurrent same-key duplicates is live-only — test that case with a live key.
  • Seed commitments are one-shot in both modes. A commitment_id is consumed by the first POST /vm/buy/init that names it — whether or not that buy succeeds — in sandbox exactly as in live, so a retry after a simulated stock-out gets 409 BUY_COMMITMENT_ALREADY_USED in both.
  • Sandbox commitments can expire; live ones do not. An outstanding sandbox commitment_id may later return 404 BUY_COMMITMENT_NOT_FOUND — mint a fresh one rather than treating it as a bug. Live commitments stay spendable until used.
  • No provably-fair draw. Sandbox pulls are simulated rather than drawn against the fairness protocol: a client_seed you send to POST /vm/buy/init is accepted and then ignored, no fairness proof is recorded, and the fairness endpoints return nothing for a sandbox session. POST /vm/buy/commit still works, so you can wire the commit → buy call sequence end-to-end, but there is no seed to reveal at the end of it. Verify the proof recipe itself against a live key — see Provable fairness.
  • Sellback pricing is frozen at buy time — in both modes. A sandbox POST /vm/buyback pays the buyback_price fixed on the item when it was bought, and live does the same: it pays the FMV and the pack's buyback percent as they stood at purchase, not today's. What differs is only that sandbox values come out of the simulation, so don't assert on exact sandbox payouts.
  • Shipping orders don't progress. A sandbox POST /ship/request creates an order and leaves it there — shipped_at and delivered_at stay null for the life of the process, since no carrier actually moves the card. (tracking_number / tracking_url are not a sandbox difference: they are never populated in either mode — see GET /ship/order/{order_id}.)
  • Scoped by user_id only. Sandbox state is isolated by the user_id you submit; a live key is additionally partner-scoped. Webhooks fire in both modes — register a sandbox endpoint to receive sandbox events (a sandbox event never reaches a live URL), so you can test your receiver for free.

Errors

Errors use one consistent JSON shape and a conventional HTTP status:

{ "error": "Claw machine is out of stock", "code": "OUT_OF_STOCK" }

error is a human-readable message; code is a stable, machine-readable token — branch on code, not the message text. Input-validation failures use code: "VALIDATION_ERROR" and add a details array listing every field problem at once:

{
  "error": "Amount must be a whole number",
  "code": "VALIDATION_ERROR",
  "details": [{ "field": "amount", "message": "Amount must be a whole number" }]
}

Statuses follow the usual conventions: 400 bad request, 401 unauthenticated, 402 insufficient prepaid balance, 403 wrong environment for the credential, 404 not found, 409 idempotency conflict, 422 unprocessable. Each operation below documents the exact codes it can return.

Webhooks

Rather than poll, subscribe to outbound events — shipping lifecycle, purchase settlement/failure, and buyback settlement — by registering a URL in your partner dashboard. Phygitals then POSTs each event to your endpoint. Deliveries are HMAC-signed (X-Phygitals-Signature) and sent at-least-once with retries, so verify the signature and dedupe on the envelope's id (stable across redeliveries — not data.idempotency_key, which is null when the originating request sent no key). Register or update your endpoint under your partner dashboard → Webhooks (/partners/<your-slug>/webhooks, owner/editor only). The full payloads, and the registration walkthrough, are in the Webhooks section below.

Next steps

  1. Mint a sandbox key from your partner dashboard.
  2. Call GET /api/partner/sandbox/v1/vm/available to list packs.
  3. Walk a full buy → item → shipment flow in sandbox.
  4. Register a webhook and confirm you receive events.
  5. Ask Phygitals to enable a live key when you're ready for production.

For long-form integration guides, see the linked Partner integration guides.

Production

Client Libraries

Packs

Browse the storefront. A pack is a themed, purchasable bundle (the id the claw_ids filter references) that draws items provably-fair when bought. These endpoints list the available packs, surface each pack's chase cards (the headline high-value rare items, via /vm/chase/{slug}), and report the recent pulls drawn across packs.

List available packs

Lists the packs the caller is allowed to browse and buy from — the catalog endpoint every integration starts at. There is no pagination. Each entry carries the two identifiers the rest of the API is addressed by: id, which you pass to POST /vm/buy/init, and slug, which addresses GET /vm/chase/{slug} and GET /packs/{pack_slug}/items.

What you see depends on how your partner is configured. A partner with platform browsing enabled sees only its own packs, and the platform query parameter is ignored for it. A reseller partner sees the first-party catalog instead — mainnet by default, or the storefront named by platform. Either way only enabled packs appear, so a pack missing from this list is not purchasable.

Query Parameters
  • platform
    Type: string

    Storefront key override when the partner is not browse-enabled. Ignored when browse is enabled. Empty/omitted defaults to mainnet.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/vm/available
curl https://api.phygitals.com/api/v1/vm/available \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
[
  {
    "id": "13",
    "slug": "rookie-pack",
    "platform": "mainnet",
    "enable": true,
    "type": "EBAY",
    "name": "Rookie Pack",
    "max_per_mint": 8,
    "mint_price": "25",
    "description": null,
    "in_stock": true,
    "num_pulls_7d": 5200,
    "chase": [],
    "rarity_distribution": [
      {
        "id": 0,
        "lower": 13,
        "upper": 25,
        "weight": 80,
        "name": "Common",
        "color": "#22C55E"
      },
      {
        "id": 1,
        "lower": 25,
        "upper": 50,
        "weight": 15,
        "name": "Uncommon",
        "color": "#3b82f6"
      },
      {
        "id": 2,
        "lower": 50,
        "upper": 150,
        "weight": 4,
        "name": "Epic",
        "color": "#EF4444"
      },
      {
        "id": 3,
        "lower": 150,
        "upper": 10000,
        "weight": 1,
        "name": "Mythic",
        "color": "#F59E0B"
      }
    ],
    "ev": 26.22,
    "ev_updated_at": "2026-07-14T12:00:00.000Z",
    "category": "pokemon",
    "categories": [
      "pokemon"
    ],
    "min_ev": 24.75,
    "max_ev": 27,
    "buyback_percent": 0.85,
    "repack": false,
    "claw_image_url": null,
    "creator_profile": {
      "id": "did:privy:cm89av1a200kz28daow0x9bjq"
    },
    "rewards_amounts": [],
    "sellback_rewards_amounts": [],
    "rewards_mint_addresses": [],
    "rewards_symbols": [],
    "rewards_decimals": [],
    "last_pull": "2026-07-14T13:03:46.300Z",
    "variant_of": null
  }
]

List a pack's chase cards

Returns a pack's chase cards — its headline high-value pulls, the ones worth showing a buyer as a "what could I pull?" preview before they purchase. Up to 60 come back, drawn from the pack's own top rarity band, each already carrying its images so the list renders as a gallery without a follow-up call per card.

slug is the slug from GET /vm/available. There is no not-found error on this route: a slug matching no pack, and a pack with no chase cards to show, both return an empty array — so check the array's length rather than expecting a 404.

These are the pack's best-case outcomes, not a promise about any particular buy. The odds live in the pack's rarity_distribution on GET /vm/available, and what a purchase actually drew is in the nfts that POST /vm/buy/init returns.

Path Parameters
  • slug
    Type: string
    required

    Pack slug (the slug from /vm/available).

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/vm/chase/{slug}
curl https://api.phygitals.com/api/v1/vm/chase/rookie-pack \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
[
  {
    "id": "Atv5Xigpcf7EvDqKJxoyp8CxZeo8m8NZdjFkER8dEi7c",
    "name": "2000 Pokemon Gym Challenge 1st Edition Holo Brock's Ninetales #3 PSA 10",
    "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2FApFiQSzv1NCRUpSZtzedkKYhuYLyo91xpLb5FcMSsdVD-cropped",
    "fmv": 2182.12,
    "back_image": null
  }
]

List recent pulls

Returns the newest pulls from the packs you can browse, newest first, as a live activity ticker. Each row names the pack (claw_id, claw_slug), the card's metadata, and when it was pulled. There is no cursor — this is a "latest N" feed, not a paginated history.

claw_ids narrows the feed to specific packs and accepts either a single id or a list. On a live key it intersects with the packs your key can browse rather than widening them, so an id outside that set contributes nothing and returns no error, and a filter matching nothing yields an empty array; the scope is the same one GET /vm/available lists. Do not rely on that as an access-control boundary in sandbox, where the filter is applied as given: a sandbox key can name a first-party pack it is not shown and receive that pack's real pull rows, and an unfiltered sandbox call reads across first-party packs regardless of browse scope.

Note also that rows are scoped by pack and not by buyer, so a reseller partner reading the shared first-party catalog sees pulls made by other buyers of those packs, not only its own end-users.

The two money fields answer different questions. value is what the card is worth now, while buyback_price is the sellback terms that pull froze at the moment of purchase — so it is buyback_price, not anything recomputed from value, that a later sellback actually honors.

On a sandbox key the feed also includes that sandbox's own simulated pulls, merged into the same newest-first ordering.

Query Parameters
  • claw_ids

    Pack id(s) to filter pulls by — a single id or an array of ids.

    • Type: string

      Pack id(s) to filter pulls by — a single id or an array of ids.

  • limit
    Type: integer
    min:  
    1
    max:  
    100

    Maximum number of pulls to return (integer 1–100; defaults to 20; junk falls back to 20).

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/vm/recent-pulls
curl https://api.phygitals.com/api/v1/vm/recent-pulls \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
[
  {
    "id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
    "claw_id": "13",
    "claw_slug": "rookie-pack",
    "value": 425.17,
    "buyback_price": 361.39,
    "created_at": "2026-07-14T13:03:46.300Z",
    "metadata": {
      "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
      "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
      "back_image": null,
      "attributes": [
        {
          "trait_type": "Grade",
          "value": "PSA 10"
        },
        {
          "trait_type": "Category",
          "value": "Pokemon"
        }
      ]
    }
  }
]

List a pack's backing inventory

Enumerates the live backing inventory of a pack: every item currently in the pool that backs the pack's expected value, each with its own fmv and category. Items come back in the same shape GET /inventory/available returns, so one renderer handles both. This is the item-level breakdown behind the EV band GET /vm/available reports — the same set of items, under the same availability and rarity-tier eligibility, that the EV is computed from — so it is what to use when you want to show buyers what is actually in a pack rather than a summary number. The whole backing set is paginated in a stable order.

Access is scoped to the packs you can browse. VM_NOT_FOUND when the pack is not available to your partner.

Path Parameters
  • pack_slug
    Type: string
    required

    Pack slug (the slug from /vm/available).

Query Parameters
  • cursor
    Type: string

    Opaque pagination cursor from a previous response's pagination.next_cursor. Omit for the first page; pass back the exact value to continue. An absent, empty, or oversized cursor reads as the first page, but a corrupted-but-plausible one is not reset — it may return an empty page.

  • limit
    Type: integer
    min:  
    1
    max:  
    100

    Maximum items per page (integer 1–100; defaults to 50; junk falls back to 50).

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/packs/{pack_slug}/items
curl https://api.phygitals.com/api/v1/packs/rookie-pack/items \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "data": [
    {
      "id": "Atv5Xigpcf7EvDqKJxoyp8CxZeo8m8NZdjFkER8dEi7c",
      "name": "2000 Pokemon Gym Challenge 1st Edition Holo Brock's Ninetales #3 PSA 10",
      "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2FApFiQSzv1NCRUpSZtzedkKYhuYLyo91xpLb5FcMSsdVD-cropped",
      "fmv": 2182.12,
      "category": "pokemon"
    },
    {
      "id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
      "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
      "fmv": 425.17,
      "category": "pokemon"
    }
  ],
  "pagination": {
    "limit": 50,
    "count": 128,
    "has_more": true,
    "next_cursor": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK"
  }
}

Items

Look up an individual item, browse a user's inventory, and browse the pack-eligible supply pool.

List a user's items

Lists the cards one of your end-users currently holds — everything they have bought and neither sold back nor shipped. user_id is your own opaque id for that person, the same one you passed to POST /vm/buy/init; it is scoped to your partner, so the same string under a different partner is a different user. There is no pagination, so a user with a large holding returns all of it in one body.

Two fields drive the sellback decision and are worth reading together: buyback_price is the exact amount POST /vm/buyback will credit for the card, and once buyback_expires_at has passed that call rejects it with ITEM_EXPIRED. Before quoting shipping, check each item's shipping block rather than assuming every holding is shippable — ungraded cards in particular read as ineligible and are rejected by POST /ship/quote.

404 USER_NOT_FOUND means this user has no purchase history with you at all; it is not the empty-inventory response. A user who bought and then sold back or shipped everything returns 200 with an empty items array, so branch on the array's length rather than on the status code.

Path Parameters
  • user_id
    Type: string
    required

    Your partner-defined user id whose items to list.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/inventory/{user_id}
curl https://api.phygitals.com/api/v1/inventory/user_42 \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "user_id": "user_42",
  "items": [
    {
      "id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "content": {
        "metadata": {
          "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
          "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
          "back_image": null,
          "attributes": [
            {
              "trait_type": "Grade",
              "value": "PSA 10"
            },
            {
              "trait_type": "Category",
              "value": "Pokemon"
            }
          ]
        },
        "links": {
          "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
          "back_image": null
        }
      },
      "buyback_price": 361.39,
      "type": "enft",
      "mint_address": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "collection_address": null,
      "token_standard": null,
      "claw_id": "13",
      "claw_slug": "rookie-pack",
      "purchased_at": "2026-07-14T13:03:46.300Z",
      "buyback_expires_at": "2026-07-21T13:03:46.300Z",
      "shipping": {
        "eligible": true,
        "method": "alt",
        "reason": "Graded vault item fulfilled via the alt vault."
      }
    }
  ]
}

Get an item

Returns the public detail for a single card. This is a catalog read, not an ownership read — it resolves any card Phygitals knows about, whether or not one of your end-users holds it, so use it to render a card page from an id you got out of a pull, a pack's backing inventory, or a chase list. For what a specific user actually owns, call GET /inventory/{user_id}.

item_id accepts three kinds of identifier and resolves them in order: first as an EbayListing id, then, if that misses, as an NFT mint address or a slug. The id in the response is the identifier the lookup landed on, which is the mint address when you looked the card up by slug — so echo back what the response gives you rather than the string you sent.

A card with no valuation on file reads as fmv: 0 rather than null, so treat a zero FMV as "not priced" rather than "worthless".

Path Parameters
  • item_id
    Type: string
    required

    Item identifier — an EbayListing id, NFT mint address, or slug.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/card/{item_id}
curl https://api.phygitals.com/api/v1/card/6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
  "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
  "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
  "back_image": null,
  "fmv": 425.17,
  "metadata": {
    "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
    "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
    "back_image": null,
    "attributes": [
      {
        "trait_type": "Grade",
        "value": "PSA 10"
      },
      {
        "trait_type": "Category",
        "value": "Pokemon"
      }
    ]
  }
}

Browse available inventory

The pool of vault items currently available to back a pack — every in-stock, unsold, vault-custodied deposit that is eligible to be added to a pack, each with its per-item fmv and category. This is the same eligibility the pack builder applies, so an item listed here is one that can actually go into a pack (never a sold, reserved, or out-of-custody card). Narrow it with category (repeat the parameter for several), order it with sort, and scope it to one pack with pack_slug: pack_filter=exclude (the default) lists what could still be ADDED to that pack, pack_filter=include lists that pack's members that are still pack-eligible. Both modes apply the eligibility gate above, so include is a view of the pack's currently-addable contents, not a full manifest: a member that has gone ineligible — mid draw-cooldown, out of custody, or missing a title/FMV — is omitted, and pagination.count counts only what is listed. For a pack's complete backing inventory use GET /packs/{pack_slug}/items instead. Without pack_slug the whole eligible pool is browsed — every partner credential may do this, and the page it returns is the same first-party supply, with the same per-item fmv, whichever key asks. That is deliberate: the pool is the first-party catalog you build packs from, so it is published to partners rather than scoped per account. Results come back in the standard cursor-native page envelope (data + pagination) — read the first page without a cursor, then follow pagination.next_cursor until pagination.has_more is false, keeping sort and the filters identical for the whole walk (a cursor is an anchor into ONE ordering; changing the sort or filters mid-walk makes the remaining pages meaningless). pack_slug is scoped to packs you can browse: an unknown pack, or one you're not authorized to see, returns 404 VM_NOT_FOUND (it never discloses another partner's pack). Unknown category / sort / pack_filter values are rejected as 400 validation errors, listing the accepted values; an out-of-range or junk limit degrades to the default instead.

Query Parameters
  • category

    Category to restrict the pool to — repeat the parameter for several. Omit for every category.

    • Type: string enum

      Category to restrict the pool to — repeat the parameter for several. Omit for every category.

      values
      • pokemon
      • basketball
      • football
      • baseball
      • one piece
  • sort
    Type: string enum

    Result ordering. fmv-high-low (the default) and fmv-low-high order by value, name-asc / name-desc by the item's stored title, category-asc / category-desc by category. Every ordering breaks ties on item id, so a page walk is stable.

    values
    • fmv-high-low
    • fmv-low-high
    • name-asc
    • name-desc
    • category-asc
    • category-desc
  • pack_slug
    Type: string

    Optional pack to scope the pool to, by slug (the slug from /vm/available). Combine with pack_filter. Omit to browse the whole eligible pool.

  • pack_filter
    Type: string enum

    How pack_slug scopes the pool: exclude (the default) lists items NOT yet in that pack — what could still be added to it, narrowed to the vaults that pack accepts — while include lists that pack's members that are still pack-eligible (not its full manifest; use GET /packs/{pack_slug}/items for that). Ignored when pack_slug is omitted.

    values
    • exclude
    • include
  • cursor
    Type: string

    Opaque pagination cursor from a previous response's pagination.next_cursor. Omit for the first page; pass back the exact value to continue, keeping sort and the filters unchanged. An absent, empty, or oversized cursor reads as the first page, but a corrupted-but-plausible one is not reset — it may return an empty page.

  • limit
    Type: integer
    min:  
    1
    max:  
    1000

    Maximum items per page (integer 1–1000; defaults to 50; junk falls back to 50).

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/inventory/available
curl https://api.phygitals.com/api/v1/inventory/available \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "data": [
    {
      "id": "Atv5Xigpcf7EvDqKJxoyp8CxZeo8m8NZdjFkER8dEi7c",
      "name": "2000 Pokemon Gym Challenge 1st Edition Holo Brock's Ninetales #3 PSA 10",
      "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2FApFiQSzv1NCRUpSZtzedkKYhuYLyo91xpLb5FcMSsdVD-cropped",
      "fmv": 2182.12,
      "category": "pokemon"
    },
    {
      "id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
      "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
      "fmv": 425.17,
      "category": "pokemon"
    }
  ],
  "pagination": {
    "limit": 50,
    "count": 1284,
    "has_more": true,
    "next_cursor": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK"
  }
}

Purchases

Buy a pack

Buys amount packs and returns the picked NFTs, drawn provably-fair from the pack. Live for live keys (draws real vaulted inventory, commits ownership, and debits your prepaid partner ledger); simulated for sandbox keys. Settlement is synchronous — nfts are in the init response (also look up via POST /vm/buy/status or the purchase.settled webhook). Pass your own client_seed to contribute the client half of the provably-fair draw; omit it and the platform seeds with the session_id it mints.

For a verifiable pre-commitment, call POST /vm/buy/commit first and pass the commitment_id it returns here: the draw then uses the server seed whose hash you already hold. Pair it with your own client_seed so neither side alone fixes the outcome — without one the platform picks both halves of the draw input, and the commitment proves only that the hash predated the draw. Omit commitment_id and the seed is minted at draw time (unchanged behavior).

A commitment_id is consumed before the draw runs, so it backs exactly one attempt: if this call fails — sold out, insufficient balance — that commitment is spent and retrying it returns 409 BUY_COMMITMENT_ALREADY_USED. Mint a fresh one with POST /vm/buy/commit (free, no body) and buy again. Guards that don't depend on the seed — unknown or disabled pack, over max_per_mint — reject before the commitment is touched.

Retries of a SETTLED buy are unaffected by commitment_id: a repeat call under an Idempotency-Key that already bought is 409 DUPLICATE_REQUEST whether or not it names the commitment that buy spent, so your retry branch stays the same on both paths. Either way the settled purchase is readable with POST /vm/buy/status.

Headers
  • Idempotency-Key
    Type: string

    Optional idempotency key for this write — equivalent to the body's idempotency_key (which takes precedence if both are sent). Retrying with the same key and body replays the original response. See the overview's Idempotency section.

Body
required
application/json
  • amount
    Type: integer
    greater than:  
    0
    min:  
    -9007199254740991
    max:  
    9007199254740991
    required

    Number of packs to buy (a positive integer, bounded by the pack's max_per_mint).

  • id
    Type: string
    min length:  
    1
    required

    Pack id to buy from (the id from /vm/available).

  • user_id
    Type: string
    min length:  
    1
    required

    Your partner-defined user id to attribute the pulled items to.

  • client_seed
    Type: string
    min length:  
    1
    max length:  
    128
    Pattern: ^[\x21-\x39\x3B-\x7E]+$

    Optional provably-fair client seed to draw with. Supply your own (per buy, chosen before you call) so the client half of the draw is a value you fixed rather than one the platform picked: it is combined with a server seed — minted at draw time, or the one you already hold the hash of when you pass a commitment_id — and revealed afterwards, and both are returned by the fairness-proof endpoints so you can re-derive every pick yourself. 1–128 printable ASCII characters, no spaces, and no : — the draw's HMAC messages use it as a field separator, so a seed containing one would make them ambiguous. Defaults to the session_id the platform mints for the purchase. Ignored by sandbox keys, whose pulls are simulated rather than drawn and record no proof.

  • commitment_id
    Type: string Format: uuid

    A commitment_id from POST /vm/buy/commit, to draw with the server seed you already hold the hash of. Consumed by this call before the draw runs, so it backs exactly ONE attempt: if this buy fails, mint a fresh commitment rather than retrying this one. The resulting session gets its own session_id — this id is not reused as one. Omit to have the seed minted at draw time.

  • idempotency_key
    Type: string

    Optional idempotency key for this write. Equivalent to the Idempotency-Key header; if both are sent, this body value wins. A repeat call under a key that already succeeded is never re-executed — it replays the original response (with an idempotency-replayed: true header), or under a race fails 409 DUPLICATE_REQUEST. Reusing a key with a different body is rejected 422 IDEMPOTENCY_KEY_REUSED; a key whose only prior attempt failed is not blocked (the retry runs fresh, since nothing was charged). See the overview's Idempotency section.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for post/vm/buy/init
curl https://api.phygitals.com/api/v1/vm/buy/init \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'X-API-Key: YOUR_SECRET_TOKEN' \
  --data '{
  "id": "13",
  "amount": 1,
  "user_id": "user_42"
}'
{
  "session_id": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
  "nfts": [
    {
      "id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "content": {
        "metadata": {
          "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
          "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
          "back_image": null,
          "attributes": [
            {
              "trait_type": "Grade",
              "value": "PSA 10"
            },
            {
              "trait_type": "Category",
              "value": "Pokemon"
            }
          ]
        }
      },
      "buyback_price": 361.39,
      "type": "enft",
      "mint_address": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "collection_address": null,
      "token_standard": null
    }
  ],
  "idempotency_key": "buy-rookie-2026-07-14-001"
}
deprecated

Look up a purchase (deprecated)

Deprecated — prefer GET /vm/sessions/{session_id} or GET /vm/sessions/by-idempotency-key/{idempotency_key}, which expose each single-key lookup as its own URL so you don't have to construct the request-body union, and GET /vm/sessions to enumerate your sessions. This POST route stays supported for back-compat and resolves the identical outcome.

Looks up a POST /vm/buy/init attempt by session_id and/or by the idempotency key you sent with it, scoped to your partner. At least one is required; supplying neither is a validation error. Only the key path can resolve a failed attempt — a failure never mints a session_id to look up by.

Sending both is allowed and cross-checks them: session_id is the lookup key, and idempotency_key must be the key that purchase was made under, so a pair naming two different purchases resolves not_found rather than quietly answering about one of them.

There is no pending state. Partner buys are synchronous, so a lookup resolves the instant the originating call returns.

Body
required
application/json
    • session_id
      Type: string
      required

      Purchase session id returned by /vm/buy/init.

    • idempotency_key
      Type: string

      Optional cross-check when looking up by session_id: the Idempotency-Key the purchase was made under. The lookup resolves not_found if the session was made under a different key.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for post/vm/buy/status
curl https://api.phygitals.com/api/v1/vm/buy/status \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'X-API-Key: YOUR_SECRET_TOKEN' \
  --data '{
  "session_id": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90"
}'
{
  "status": "fulfilled",
  "idempotency_key": "buy-rookie-2026-07-14-001",
  "result": {
    "session_id": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
    "user_id": "user_42",
    "public_id": "_k7m2n9p4qxw",
    "nfts": [
      {
        "id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
        "content": {
          "metadata": {
            "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
            "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
            "back_image": null,
            "attributes": [
              {
                "trait_type": "Grade",
                "value": "PSA 10"
              },
              {
                "trait_type": "Category",
                "value": "Pokemon"
              }
            ]
          }
        },
        "buyback_price": 361.39,
        "type": "enft",
        "mint_address": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
        "collection_address": null,
        "token_standard": null
      }
    ],
    "tx_hash": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90"
  },
  "failure_reason": null
}

List buy sessions

Pages through your buy sessions, newest first — the historical companion to the single-session lookups, and the replacement for the deprecated POST /vm/buy/status. Use it to reconcile a day's purchases in bulk rather than resolving them one id at a time.

A fulfilled row is a completed POST /vm/buy/init, carrying under result the same session GET /vm/sessions/{session_id} returns. A failed row is a recorded failed attempt — and only attempts made with an idempotency key are recorded, so a buy sent without one leaves no trace here when it fails. That is the practical argument for sending a key on every buy.

status and user_id filter the list and apply together when you send both. It is always scoped to your own partner.

Query Parameters
  • status
    Type: string enum

    Filter to sessions in this state — fulfilled (completed buys) or failed (recorded failed attempts). Omit to list both.

    values
    • fulfilled
    • failed
  • user_id
    Type: string

    Filter to sessions for this partner-defined end-user id (the user_id you passed at buy time).

  • cursor
    Type: string

    Opaque pagination cursor from a previous response's pagination.next_cursor. Omit for the first (newest) page; pass the exact value back to continue. Junk degrades to the first page.

  • limit
    Type: integer
    min:  
    1
    max:  
    100

    Maximum number of sessions to return (integer 1–100; defaults to 20; junk falls back to 20).

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/vm/sessions
curl https://api.phygitals.com/api/v1/vm/sessions \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "data": [
    {
      "status": "fulfilled",
      "user_id": "user_42",
      "idempotency_key": "buy-rookie-2026-07-14-001",
      "result": {
        "session_id": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
        "user_id": "user_42",
        "public_id": "_k7m2n9p4qxw",
        "nfts": [
          {
            "id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
            "content": {
              "metadata": {
                "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
                "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
                "back_image": null,
                "attributes": [
                  {
                    "trait_type": "Grade",
                    "value": "PSA 10"
                  },
                  {
                    "trait_type": "Category",
                    "value": "Pokemon"
                  }
                ]
              }
            },
            "buyback_price": 361.39,
            "type": "enft",
            "mint_address": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
            "collection_address": null,
            "token_standard": null
          }
        ],
        "tx_hash": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90"
      },
      "failure_reason": null,
      "created_at": "2026-07-14T13:03:46.300Z"
    },
    {
      "status": "failed",
      "user_id": "user_42",
      "idempotency_key": "buy-rookie-2026-07-13-009",
      "result": null,
      "failure_reason": "OUT_OF_STOCK",
      "created_at": "2026-07-13T09:20:11.000Z"
    }
  ],
  "pagination": {
    "limit": 20,
    "count": 2,
    "has_more": false,
    "next_cursor": null
  }
}

Look up a session by id

Resolves one buy session by the session_id that POST /vm/buy/init returned — the durable record of what a purchase drew, for reconciling after the fact or re-rendering a reveal. This is the GET replacement for POST /vm/buy/status's session_id path, addressed by URL so you don't have to construct the request-body union.

Because partner buys settle synchronously, a session exists only for a purchase that completed; there is no pending state to poll through, and a failed attempt has no session_id to look up. Find those by their idempotency key with GET /vm/sessions/by-idempotency-key/{idempotency_key}, or list them with GET /vm/sessions.

SESSION_NOT_FOUND when no session is available for that id under your partner. Note this differs from the deprecated POST /vm/buy/status, which answers a miss with 200 and a not_found envelope instead of a 404.

Path Parameters
  • session_id
    Type: string
    min length:  
    1
    required

    Purchase session id returned by POST /vm/buy/init.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/vm/sessions/{session_id}
curl https://api.phygitals.com/api/v1/vm/sessions/0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90 \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "status": "fulfilled",
  "idempotency_key": "buy-rookie-2026-07-14-001",
  "result": {
    "session_id": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
    "user_id": "user_42",
    "public_id": "_k7m2n9p4qxw",
    "nfts": [
      {
        "id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
        "content": {
          "metadata": {
            "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
            "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
            "back_image": null,
            "attributes": [
              {
                "trait_type": "Grade",
                "value": "PSA 10"
              },
              {
                "trait_type": "Category",
                "value": "Pokemon"
              }
            ]
          }
        },
        "buyback_price": 361.39,
        "type": "enft",
        "mint_address": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
        "collection_address": null,
        "token_standard": null
      }
    ],
    "tx_hash": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90"
  },
  "failure_reason": null
}

Look up a session by idempotency key

Resolves a buy attempt by the idempotency key you sent with the original POST /vm/buy/init. This is the lookup to reach for when a buy's response never arrived: you always hold the key you sent, even when you never learned the session_id. It is the GET replacement for POST /vm/buy/status's idempotency_key path.

Unlike the by-id lookup, this one resolves failures as well as successes, so a failed result with its failure_reason is enough to decide whether retrying is worthwhile. Failed attempts are recorded only when a key was sent, which is the practical argument for sending one on every buy.

SESSION_NOT_FOUND means no attempt is on record for this key under your partner. Keys are partner-scoped, so another partner's key never resolves here.

Path Parameters
  • idempotency_key
    Type: string
    min length:  
    1
    required

    The Idempotency-Key sent with the original /vm/buy/init call, scoped to your partner.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/vm/sessions/by-idempotency-key/{idempotency_key}
curl https://api.phygitals.com/api/v1/vm/sessions/by-idempotency-key/buy-rookie-2026-07-14-001 \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "status": "fulfilled",
  "idempotency_key": "buy-rookie-2026-07-14-001",
  "result": {
    "session_id": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
    "user_id": "user_42",
    "public_id": "_k7m2n9p4qxw",
    "nfts": [
      {
        "id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
        "content": {
          "metadata": {
            "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
            "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
            "back_image": null,
            "attributes": [
              {
                "trait_type": "Grade",
                "value": "PSA 10"
              },
              {
                "trait_type": "Category",
                "value": "Pokemon"
              }
            ]
          }
        },
        "buyback_price": 361.39,
        "type": "enft",
        "mint_address": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
        "collection_address": null,
        "token_standard": null
      }
    ],
    "tx_hash": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90"
  },
  "failure_reason": null
}
deprecated

Look up a buyback (deprecated)

Deprecated — prefer GET /vm/buybacks/{buyback_id} or GET /vm/buybacks/by-idempotency-key/{idempotency_key}, which expose each single-key lookup as its own URL so you don't have to construct the request-body union. This POST route stays supported for back-compat and resolves the identical outcome.

Looks up a POST /vm/buyback by buyback_id and/or by the idempotency key you sent with it, scoped to your partner. At least one is required; supplying neither is a validation error. Sending both cross-checks them: buyback_id is the lookup key and idempotency_key must be the key that buyback was credited under, so a pair naming two different buybacks resolves not_found.

An item_id is deliberately not a key here. A sold-back card returns to the shared pool and can be bought and sold back again, so one item accumulates many buybacks and no single one of them is the buyback for that item.

Body
required
application/json
    • buyback_id
      Type: string
      required

      Buyback id returned by POST /vm/buyback.

    • idempotency_key
      Type: string

      Optional cross-check when looking up by buyback_id: the Idempotency-Key the buyback was credited under. The lookup resolves not_found if it was credited under a different key.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for post/vm/buyback/status
curl https://api.phygitals.com/api/v1/vm/buyback/status \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'X-API-Key: YOUR_SECRET_TOKEN' \
  --data '{
  "buyback_id": "0194f0a3-1d4e-7b2f-8c3d-6a9f5e2b7d01"
}'
{
  "status": "credited",
  "idempotency_key": "buy-rookie-2026-07-14-001",
  "result": {
    "buyback_id": "0194f0a3-1d4e-7b2f-8c3d-6a9f5e2b7d01",
    "item_id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
    "amount": 361.39,
    "buy_session_id": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
    "credited_at": "2026-07-14T14:10:00.000Z",
    "card": {
      "id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "content": {
        "metadata": {
          "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
          "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
          "back_image": null,
          "attributes": [
            {
              "trait_type": "Grade",
              "value": "PSA 10"
            },
            {
              "trait_type": "Category",
              "value": "Pokemon"
            }
          ]
        }
      },
      "buyback_price": 361.39,
      "type": "enft",
      "mint_address": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "collection_address": null,
      "token_standard": null
    },
    "user_id": "user_42"
  }
}

List buybacks

Pages through your credited buybacks, newest first. Each row is the same shape the single-buyback lookups return, so the list and GET /vm/buybacks/{buyback_id} never drift.

item_id selects buybacks of one card — and legitimately returns several, because a sold-back card goes back into the shared pool and can be bought and sold back again, one buyback per round. user_id selects buybacks for one of your end-users, using the partner-defined id you passed at buy time. Sending both applies them together. The list is always scoped to your own partner.

Query Parameters
  • item_id
    Type: string

    Filter to buybacks of this item id. One item can have many buybacks (one per round it is bought).

  • user_id
    Type: string

    Filter to buybacks made for this partner-defined end-user id (the user_id you passed at buy time).

  • cursor
    Type: string

    Pagination cursor — pass the next_cursor from the previous page. Omit for the first (newest) page. Opaque: treat it as a handle to pass back verbatim.

  • limit
    Type: integer
    min:  
    1
    max:  
    100

    Maximum number of buybacks to return (integer 1–100; defaults to 20; junk falls back to 20).

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/vm/buybacks
curl https://api.phygitals.com/api/v1/vm/buybacks \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "data": [
    {
      "buyback_id": "0194f0a3-1d4e-7b2f-8c3d-6a9f5e2b7d01",
      "item_id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "amount": 361.39,
      "buy_session_id": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
      "credited_at": "2026-07-14T14:10:00.000Z",
      "card": {
        "id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
        "content": {
          "metadata": {
            "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
            "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
            "back_image": null,
            "attributes": [
              {
                "trait_type": "Grade",
                "value": "PSA 10"
              },
              {
                "trait_type": "Category",
                "value": "Pokemon"
              }
            ]
          }
        },
        "buyback_price": 361.39,
        "type": "enft",
        "mint_address": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
        "collection_address": null,
        "token_standard": null
      },
      "user_id": "user_42"
    }
  ],
  "pagination": {
    "limit": 20,
    "count": 1,
    "has_more": false,
    "next_cursor": null
  }
}

Look up a buyback by id

Resolves one buyback by the buyback_id that POST /vm/buyback returned, scoped to your partner. It is a convenience wrapper over POST /vm/buyback/status's buyback_id path, addressed by URL so you don't have to construct the request-body union, and it resolves the identical outcome.

A miss is still an HTTP 200 carrying a not_found envelope, not a 404, so branch on the status field rather than the status code. This is the opposite convention from the session lookups, which do 404.

Use GET /vm/buybacks/by-idempotency-key/{idempotency_key} when you hold your own key but never learned our id, and GET /vm/buybacks to page through them all.

Path Parameters
  • buyback_id
    Type: string
    min length:  
    1
    required

    Buyback id returned by POST /vm/buyback.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/vm/buybacks/{buyback_id}
curl https://api.phygitals.com/api/v1/vm/buybacks/0194f0a3-1d4e-7b2f-8c3d-6a9f5e2b7d01 \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "status": "credited",
  "idempotency_key": "buy-rookie-2026-07-14-001",
  "result": {
    "buyback_id": "0194f0a3-1d4e-7b2f-8c3d-6a9f5e2b7d01",
    "item_id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
    "amount": 361.39,
    "buy_session_id": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
    "credited_at": "2026-07-14T14:10:00.000Z",
    "card": {
      "id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "content": {
        "metadata": {
          "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
          "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
          "back_image": null,
          "attributes": [
            {
              "trait_type": "Grade",
              "value": "PSA 10"
            },
            {
              "trait_type": "Category",
              "value": "Pokemon"
            }
          ]
        }
      },
      "buyback_price": 361.39,
      "type": "enft",
      "mint_address": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "collection_address": null,
      "token_standard": null
    },
    "user_id": "user_42"
  }
}

Look up a buyback by idempotency key

Resolves a buyback by the idempotency key you sent with the original POST /vm/buyback, scoped to your partner. This is the lookup for the case where the buyback's response never reached you: you still hold the key you sent, even without our buyback_id. It wraps POST /vm/buyback/status's idempotency_key path, addressed by URL so you don't have to construct a request body.

One key resolves to at most one buyback — reusing a key on a new buyback is rejected at write time with 409 DUPLICATE_REQUEST, so this lookup is never ambiguous. A miss is still an HTTP 200 carrying an unknown_key envelope rather than a 404, so branch on the status field.

Path Parameters
  • idempotency_key
    Type: string
    min length:  
    1
    required

    The Idempotency-Key sent with the original /vm/buyback call, scoped to your partner.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/vm/buybacks/by-idempotency-key/{idempotency_key}
curl https://api.phygitals.com/api/v1/vm/buybacks/by-idempotency-key/buy-rookie-2026-07-14-001 \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "status": "credited",
  "idempotency_key": "buy-rookie-2026-07-14-001",
  "result": {
    "buyback_id": "0194f0a3-1d4e-7b2f-8c3d-6a9f5e2b7d01",
    "item_id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
    "amount": 361.39,
    "buy_session_id": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
    "credited_at": "2026-07-14T14:10:00.000Z",
    "card": {
      "id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "content": {
        "metadata": {
          "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
          "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
          "back_image": null,
          "attributes": [
            {
              "trait_type": "Grade",
              "value": "PSA 10"
            },
            {
              "trait_type": "Category",
              "value": "Pokemon"
            }
          ]
        }
      },
      "buyback_price": 361.39,
      "type": "enft",
      "mint_address": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "collection_address": null,
      "token_standard": null
    },
    "user_id": "user_42"
  }
}

Sell an item back

Sells one item back to Phygitals and credits your prepaid partner ledger with the payout. The card returns to the shared pool, where it becomes drawable again by a later buy — which is why an item can accumulate several buybacks over its life, one per round it is bought. Settlement is synchronous: when this call returns success, the credit has landed. Live for live keys; simulated for sandbox keys.

The payout is fixed by the terms of the purchase, not by today's market: amount is the item's FMV as recorded when your user bought it, multiplied by the pack's buyback percent as it stood at that same moment. A pack repriced afterwards does not reprice an offer already earned, in either direction. That amount is the buyback_price already shown on the item by GET /inventory/{user_id}, so you can display the exact figure before the user commits.

BUYBACK_ITEM_NOT_FOUND covers three distinct situations behind one code: the item is unknown, it is not held by one of your end-users, or it lost a race with a concurrent buyback of the same card. A short prepaid balance is not a failure mode here — a buyback credits the ledger rather than debiting it.

Headers
  • Idempotency-Key
    Type: string

    Optional idempotency key for this write — equivalent to the body's idempotency_key (which takes precedence if both are sent). Retrying with the same key and body replays the original response. See the overview's Idempotency section.

Body
required
application/json
  • item_id
    Type: string
    min length:  
    1
    required

    Id of the item to sell back.

  • idempotency_key
    Type: string

    Optional idempotency key for this write. Equivalent to the Idempotency-Key header; if both are sent, this body value wins. A repeat call under a key that already succeeded is never re-executed — it replays the original response (with an idempotency-replayed: true header), or under a race fails 409 DUPLICATE_REQUEST. Reusing a key with a different body is rejected 422 IDEMPOTENCY_KEY_REUSED; a key whose only prior attempt failed is not blocked (the retry runs fresh, since nothing was charged). See the overview's Idempotency section.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for post/vm/buyback
curl https://api.phygitals.com/api/v1/vm/buyback \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'X-API-Key: YOUR_SECRET_TOKEN' \
  --data '{
  "item_id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK"
}'
{
  "success": true,
  "buyback_id": "0194f0a3-1d4e-7b2f-8c3d-6a9f5e2b7d01",
  "amount": 361.39,
  "buy_session_id": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
  "wallet_game_code": null,
  "idempotency_key": "buy-rookie-2026-07-14-001"
}

Fairness

Verify that a pull was not rigged. Every live buy records the server seed it drew with, that seed's hash, and the draw it produced — so you, or your end-user, can re-derive the outcome from the published seeds and confirm the recorded evidence matches. By default the server seed is minted at draw time, making this a verifiable record of the draw; commit to it in advance with POST /vm/buy/commit and the same proof becomes a true pre-commitment, because you were handed sha256(server_seed) before the draw ran. Supply your own client_seed on the buy either way, so the draw input isn't ours alone. Read a purchase's evidence by its session, or enumerate your recorded proofs. The end-to-end walkthrough is in the Provable fairness section of the introduction.

Commit a buy seed

Commits us to a server seed BEFORE any draw runs, returning only its hash. Show server_seed_hash to your end-user, then pass commitment_id — and your own client_seed, so that both halves of the draw input aren't ours — to POST /vm/buy/init. That call mints and returns its own session_id; GET /vm/sessions/{session_id}/fairness-proof then reveals the seed, and sha256(server_seed) must equal the hash this call gave you — which is what proves the commitment predated the draw.

Treat commitment_id like a secret and store it securely. The end-to-end walkthrough, with the checks that make the revealed seed evidence, is in the Provable fairness section of the introduction.

Body
application/json
Empty object
Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for post/vm/buy/commit
curl https://api.phygitals.com/api/v1/vm/buy/commit \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'X-API-Key: YOUR_SECRET_TOKEN' \
  --data '{}'
{
  "commitment_id": "0194f0a4-2e5f-7c3a-9b4e-7d0a6f3c8e12",
  "server_seed_hash": "6c2f8ab3d95174e0bf3a7c6d2e9018b45f7a3c1d8e6b204f9a7c3e5d1b8f0426"
}

Get a session's fairness proof

The provably-fair evidence for one buy session's draw — the seeds and per-draw detail that let you (or your end-user) independently confirm the pull was not rigged. Addressed by the session_id POST /vm/buy/init returned, and scoped to your partner.

Verifying the seeds

sha256(server_seed) must equal server_seed_hash in every case. What that proves depends on how the buy was made:

  • You committed first via POST /vm/buy/commit — the hash was issued before the draw ran, so checking it against the value THAT call returned (the copy you stored) is what makes it a genuine commit-reveal.
  • The seed was minted on demand at draw time — both columns were recorded together, so the check confirms the record is internally consistent rather than pre-committed.

committed_at is when we recorded the seed and is populated either way, so it identifies neither case on its own; your own stored hash does. client_seed is the seed you sent on POST /vm/buy/init — or, when you sent none, the session id itself.

How a draw picks each item

A draw picks each item in two steps, and every selections entry records the value BOTH steps generated. Both steps' values re-derive from the revealed seeds alone, so each step's RNG checks out on its own.

Step one — the rarity tier. tierRandom is HMAC_SHA256(server_seed, client_seed + ":" + index), read as a float in [0,1) from its first 13 hex characters, walked against rarity_distribution on the proof itself: the tier bands and weights as they were configured when this draw ran, in the order the cumulative-weight walk consumed them.

  • Check tierRandom against those, not against the pack's current weights from GET /packs/{slug} — a pack's tiers are re-tuned over time, so the live configuration is not evidence about a past draw.
  • rarity_distribution is null when no distribution is available for the draw — typically one recorded before the capture existed. When it is null, the pack's published weights are the best available reference.
  • One deliberate mismatch to expect when cross-checking: the FINAL tier is treated as open-ended, so a draw landing in it records tierUpper: 999999999 on the selection rather than that band's configured upper from rarity_distribution. The algorithm makes the same substitution when you re-run it, so the draw still reproduces.

Step two — the item within that tier. rowSeed is HMAC_SHA256(server_seed, client_seed + ":row:" + index), every eligible item is ranked by md5(item_id + rowSeed) and the smallest wins, and itemRandom is that value for the item the step landed on — so md5(chosenItemId + rowSeed) must reproduce it.

Checking the pick itself with candidate_pools

Reproducing the item-within-tier pick needs one more thing — the set of items that was actually ranked — and that is what candidate_pools on THIS endpoint gives you: for each selection that recorded one, every candidate item id the draw considered, captured inside the draw's own transaction so it is the pool as it stood at that instant rather than a later re-query.

Recompute md5(item_id + rowSeed) across that set and you can check the pick itself, not merely that the winner's own value was computed honestly — narrowing the pool (quietly dropping the good cards) is exactly the manipulation every other check on a proof passes through untouched.

Two things to build in before you alarm on a result:

  1. The hard check is MEMBERSHIPchosenItemId must appear in its pool.
  2. The winner is NOT always the minimum, and a draw where it isn't is usually honest. Buys run concurrently, so an item another buy is already claiming at that instant is passed over rather than ranked, and the pick legitimately falls to the next candidate. Expect a small number of candidates to rank above the winner, especially on busy packs; treat a persistently large gap on one pack as worth asking about, and a single one as normal.

candidate_pools is keyed by index, matching selections[].index — match on that, not on array position.

A selection is absent from it when:

  • the draw predates pool capture;
  • its tier had no stock;
  • the eligible set was too large to record; or
  • one of its candidate ids could not be resolved, which drops that pool whole.

Entries are never truncated, so a missing entry means "not reconstructable" and never "here is part of it".

This is also the only endpoint that carries pools — GET /vm/fairness-proofs returns the same evidence without them, so page that to find the draws you care about and fetch them here to check them.

What these pools do and don't settle

One boundary worth stating plainly: these pools let you verify the pick against the set we recorded, and that set and the pick come from a single shared definition of eligibility, so they agree by construction. Confirming that the recorded set was itself the whole eligible inventory is a separate question, answerable by comparing a pool against what GET /packs/{slug}/items says the pack held.

Errors

Returns 404 FAIRNESS_PROOF_NOT_FOUND when no proof is available for that session under your partner — including sandbox buys, which are simulated rather than drawn and so record no proof.

Path Parameters
  • session_id
    Type: string
    min length:  
    1
    required

    Purchase session id returned by POST /vm/buy/init.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/vm/sessions/{session_id}/fairness-proof
curl https://api.phygitals.com/api/v1/vm/sessions/0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90/fairness-proof \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "session_id": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
  "user_id": "user_42",
  "claw_id": "13",
  "version": "v1",
  "algorithm": "hmac_sha256",
  "server_seed": "1f9a4c7e2b8d0356a1c4e7f92b6d803f5a1c9e4b7d206f38a9c1e5b4d7f02638",
  "server_seed_hash": "6c2f8ab3d95174e0bf3a7c6d2e9018b45f7a3c1d8e6b204f9a7c3e5d1b8f0426",
  "client_seed": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
  "amount": 1,
  "selections": [
    {
      "index": 0,
      "tierId": 2,
      "tierLower": 25,
      "tierUpper": 100,
      "tierRandom": 0.4172938104,
      "rowSeed": "b71d3f0a9c58e246d1f7a3b8c05e9a2f",
      "chosenItemId": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "itemRandom": "2b90e3c40d86ba0913973c689062dbf1"
    }
  ],
  "rarity_distribution": [
    {
      "id": 0,
      "lower": 0,
      "upper": 10,
      "weight": 20
    },
    {
      "id": 1,
      "lower": 10,
      "upper": 25,
      "weight": 15
    },
    {
      "id": 2,
      "lower": 25,
      "upper": 100,
      "weight": 60
    },
    {
      "id": 3,
      "lower": 100,
      "upper": 1000,
      "weight": 5
    }
  ],
  "committed_at": null,
  "created_at": "2026-07-14T13:03:46.300Z",
  "candidate_pools": [
    {
      "index": 0,
      "item_ids": [
        "9c14a7e2-3b6d-4f80-a512-8ed0c93b47fa",
        "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
        "297455676695",
        "9xQeWvG4kR2mYt7BnZpL5cHdA3sJf1UvNkXrT8ePqMwZ",
        "did:privy:cm4t7q0xz009ab21ekwn3lp8v___shadowless-base-set-booster-box-Qm3xTa___R7dLPq"
      ]
    }
  ]
}

List fairness proofs

Pages through your recorded provably-fair draw proofs, newest first — one per buy session. Each row is the same proof GET /vm/sessions/{session_id}/fairness-proof returns, and that endpoint documents the full derivation, including what committed_at does and does not prove. This one is for reconciling in bulk rather than session by session.

Check each draw's tierRandom against the rarity_distribution carried on the proof itself — the bands and weights as they stood when that draw ran — rather than against the pack's current configuration, which is re-tuned over time.

session_id narrows to one purchase's proof and user_id to one end-user's; sending both applies them together. The list is always scoped to your own partner, so another partner's proofs and first-party marketplace pulls are never listed. Sandbox buys are simulated rather than drawn against the fairness protocol, so they record no proof and a sandbox key always reads an empty page.

Query Parameters
  • session_id
    Type: string

    Filter to the proof for this buy session id (the session_id from POST /vm/buy/init).

  • user_id
    Type: string

    Filter to proofs for this partner-defined end-user id (the user_id you passed at buy time).

  • cursor
    Type: string

    Opaque pagination cursor from a previous response's pagination.next_cursor. Omit for the first (newest) page; pass the exact value back to continue. Junk degrades to the first page.

  • limit
    Type: integer
    min:  
    1
    max:  
    100

    Maximum number of proofs to return (integer 1–100; defaults to 20; junk falls back to 20).

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/vm/fairness-proofs
curl https://api.phygitals.com/api/v1/vm/fairness-proofs \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "data": [
    {
      "session_id": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
      "user_id": "user_42",
      "claw_id": "13",
      "version": "v1",
      "algorithm": "hmac_sha256",
      "server_seed": "1f9a4c7e2b8d0356a1c4e7f92b6d803f5a1c9e4b7d206f38a9c1e5b4d7f02638",
      "server_seed_hash": "6c2f8ab3d95174e0bf3a7c6d2e9018b45f7a3c1d8e6b204f9a7c3e5d1b8f0426",
      "client_seed": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
      "amount": 1,
      "selections": [
        {
          "index": 0,
          "tierId": 2,
          "tierLower": 25,
          "tierUpper": 100,
          "tierRandom": 0.4172938104,
          "rowSeed": "b71d3f0a9c58e246d1f7a3b8c05e9a2f",
          "chosenItemId": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
          "itemRandom": "2b90e3c40d86ba0913973c689062dbf1"
        }
      ],
      "rarity_distribution": [
        {
          "id": 0,
          "lower": 0,
          "upper": 10,
          "weight": 20
        },
        {
          "id": 1,
          "lower": 10,
          "upper": 25,
          "weight": 15
        },
        {
          "id": 2,
          "lower": 25,
          "upper": 100,
          "weight": 60
        },
        {
          "id": 3,
          "lower": 100,
          "upper": 1000,
          "weight": 5
        }
      ],
      "committed_at": null,
      "created_at": "2026-07-14T13:03:46.300Z"
    }
  ],
  "pagination": {
    "limit": 20,
    "count": 1,
    "has_more": false,
    "next_cursor": null
  }
}

Shipping

Quote shipping for items

Opens a shipping quote for one end-user's cards: it validates the destination address, confirms every id in item_ids is currently held by user_id, classifies the cards for fulfillment, and returns the rates available for that shipment. Nothing is booked and no money moves — this is the pricing step, and POST /ship/request is the booking step.

The quote is all-or-nothing across item_ids: a single id the user does not hold, or a card that cannot be shipped, rejects the whole request rather than quoting the rest. Filter on each item's shipping.eligible flag from GET /inventory/{user_id} before quoting rather than discovering it here.

The response's session_id identifies the quote and is what GET /ship/quote/{session_id} reads back. It is a rate's own id from the quotes array — not the session_id — that you pass to POST /ship/request as quote_id.

SHIP_ITEM_NOT_FOUND when an item in item_ids cannot be quoted for that user. Address validation is real in sandbox as well as live — it is the one part of the shipping flow the sandbox does not simulate.

Headers
  • Idempotency-Key
    Type: string

    Optional idempotency key for this write — equivalent to the body's idempotency_key (which takes precedence if both are sent). Retrying with the same key and body replays the original response. See the overview's Idempotency section.

Body
required
application/json
  • destination
    Type: object ·
    required

    A shipping address — used as the destination on a ship/quote and echoed back as the address on a ship/order.

  • item_ids
    Type: array string[] 1…100
    required

    Ids of the items to ship.

  • user_id
    Type: string
    min length:  
    1
    required

    Your partner-defined user id who owns the items to ship.

  • idempotency_key
    Type: string

    Optional idempotency key for this write. Equivalent to the Idempotency-Key header; if both are sent, this body value wins. A repeat call under a key that already succeeded is never re-executed — it replays the original response (with an idempotency-replayed: true header), or under a race fails 409 DUPLICATE_REQUEST. Reusing a key with a different body is rejected 422 IDEMPOTENCY_KEY_REUSED; a key whose only prior attempt failed is not blocked (the retry runs fresh, since nothing was charged). See the overview's Idempotency section.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for post/ship/quote
curl https://api.phygitals.com/api/v1/ship/quote \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'X-API-Key: YOUR_SECRET_TOKEN' \
  --data '{
  "item_ids": [
    "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK"
  ],
  "destination": {
    "name": "Jamie Collector",
    "line_1": "500 Terry A Francois Blvd",
    "line_2": "Suite 300",
    "city": "San Francisco",
    "region": "CA",
    "postal_code": "94158",
    "country": "US"
  },
  "user_id": "user_42"
}'
{
  "session_id": "0194f0a5-3f60-7d4b-ae5f-8c1b7a4d9f23",
  "expires_at": "2026-07-14T14:35:00.000Z",
  "quotes": [
    {
      "id": "0194f0a5-3f60-7d4b-ae5f-8c1b7a4d9f23_rate_a1b2c3d4-e5f6-4789-abcd-ef1234567890",
      "carrier": "Phygitals",
      "service": "Graded Vault Fulfillment",
      "withdrawal_fees": 0,
      "shipping_cost": 20,
      "total_cost": 20,
      "estimated_delivery": "3-5 business days",
      "notes": "Alt vault domestic shipping: $20 flat fee per order (1 card). All prices in USD.",
      "estimated_days_min": 3,
      "estimated_days_max": 5
    }
  ]
}

Get a shipping quote

Reads back a quote created by POST /ship/quote, returning the identical shape. Use it to re-price a rate the user picked earlier — server-side, against the stored quote — instead of trusting a rate id and cost that have been sitting in a client since the quote was made.

The quote must have been created for the same end-user named by user_id, under your partner. A mismatch on either reads as QUOTE_NOT_FOUND, exactly like an id that never existed, so a caller cannot probe for another user's quotes. A malformed session id resolves the same way rather than erroring.

Expiry here is a hard cutoff with no grace period: once the quote has lapsed the only remedy is a fresh POST /ship/quote. Booking is more forgiving — POST /ship/request applies its own still-priced allowance — so a quote that fails to read back may still book, while one that reads back cleanly is certainly still bookable.

Path Parameters
  • session_id
    Type: string
    required

    Quote session id from POST /ship/quote.

Query Parameters
  • user_id
    Type: string
    min length:  
    1
    required

    Your partner-defined user id the quote was requested for (same as POST /ship/quote).

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/ship/quote/{session_id}
curl 'https://api.phygitals.com/api/v1/ship/quote/{session_id}?user_id=' \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "session_id": "0194f0a5-3f60-7d4b-ae5f-8c1b7a4d9f23",
  "expires_at": "2026-07-14T14:35:00.000Z",
  "quotes": [
    {
      "id": "0194f0a5-3f60-7d4b-ae5f-8c1b7a4d9f23_rate_a1b2c3d4-e5f6-4789-abcd-ef1234567890",
      "carrier": "Phygitals",
      "service": "Graded Vault Fulfillment",
      "withdrawal_fees": 0,
      "shipping_cost": 20,
      "total_cost": 20,
      "estimated_delivery": "3-5 business days",
      "notes": "Alt vault domestic shipping: $20 flat fee per order (1 card). All prices in USD.",
      "estimated_days_min": 3,
      "estimated_days_max": 5
    }
  ]
}

Request a shipping order

Books one of the rates a quote offered and opens the shipping order. quote_id is a rate id from the quotes array POST /ship/quote returned, and user_id must be the end-user the quote was created for. The call consumes the quote, claims the items, debits your prepaid partner ledger for the chosen rate's cost, and records the order — all before it answers, so a success response means the shipment is durably booked and paid for.

The returned order_id is the same value as the quote's session_id, so the order is addressable by GET /ship/order/{order_id} immediately. Booking also emits a shipping.queued webhook. The claimed cards leave the user's holdings permanently: they stop appearing in GET /inventory/{user_id} and can no longer be sold back.

A quote backs exactly one booking, and once consumed it cannot be re-booked — so every quote-lifecycle rejection means the same thing operationally: request a new quote. CONCURRENT_SHIP_CONFLICT is the one to watch, because a concurrent request took one or more of the items first and this quote was already consumed by the time the conflict surfaced; the retry is a fresh quote rather than a repeat of this call.

INSUFFICIENT_BALANCE means the debit would take your prepaid ledger below its floor. Nothing is charged and the booking unwinds itself — the items are released back to the user and the quote is un-consumed — so the same quote_id can be retried after a top-up, provided it has not expired in the meantime. No carrier label is bought at this point; the vault vendor handles fulfillment from here, and progress is reported through GET /ship/order/{order_id}.

Headers
  • Idempotency-Key
    Type: string

    Optional idempotency key for this write — equivalent to the body's idempotency_key (which takes precedence if both are sent). Retrying with the same key and body replays the original response. See the overview's Idempotency section.

Body
required
application/json
  • quote_id
    Type: string
    min length:  
    1
    required

    A rate/quote id from the quotes array returned by /ship/quote.

  • user_id
    Type: string
    min length:  
    1
    required

    Your partner-defined user id the quote was requested for (same as /ship/quote).

  • idempotency_key
    Type: string

    Optional idempotency key for this write. Equivalent to the Idempotency-Key header; if both are sent, this body value wins. A repeat call under a key that already succeeded is never re-executed — it replays the original response (with an idempotency-replayed: true header), or under a race fails 409 DUPLICATE_REQUEST. Reusing a key with a different body is rejected 422 IDEMPOTENCY_KEY_REUSED; a key whose only prior attempt failed is not blocked (the retry runs fresh, since nothing was charged). See the overview's Idempotency section.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for post/ship/request
curl https://api.phygitals.com/api/v1/ship/request \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'X-API-Key: YOUR_SECRET_TOKEN' \
  --data '{
  "quote_id": "0194f0a5-3f60-7d4b-ae5f-8c1b7a4d9f23_rate_a1b2c3d4-e5f6-4789-abcd-ef1234567890",
  "user_id": "user_42"
}'
{
  "order_id": "0194f0a5-3f60-7d4b-ae5f-8c1b7a4d9f23",
  "status": "success"
}

List a user's shipping orders

Returns every shipping order booked for the end-user named by user_id, newest first, in the same per-order shape GET /ship/order/{order_id} returns. Orders are scoped to your partner as well as to that user, so this never surfaces another partner's shipments.

There is no pagination: the response holds the user's whole shipping history. Quote sessions that were never booked do not appear — an order exists here only once POST /ship/request has succeeded.

Query Parameters
  • user_id
    Type: string
    min length:  
    1
    required

    Your partner-defined user id whose orders to list.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/ship/orders
curl 'https://api.phygitals.com/api/v1/ship/orders?user_id=' \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "orders": [
    {
      "order_id": "0194f0a5-3f60-7d4b-ae5f-8c1b7a4d9f23",
      "status": "tracking_available",
      "carrier": "UPS",
      "service": "Ground",
      "tracking_number": "1Z999AA10123456784",
      "tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784",
      "amount": 20,
      "currency": "USD",
      "destination": {
        "name": "Jamie Collector",
        "line_1": "500 Terry A Francois Blvd",
        "line_2": "Suite 300",
        "city": "San Francisco",
        "region": "CA",
        "postal_code": "94158",
        "country": "US"
      },
      "items": [
        {
          "item_id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
          "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
          "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
          "back_image": null
        }
      ],
      "created_at": "2026-07-14T14:20:00.000Z",
      "shipped_at": "2026-07-14T18:45:00.000Z",
      "delivered_at": null,
      "error_message": null
    }
  ]
}

Get a shipping order

Returns the current state of one shipping order. order_id is the UUID POST /ship/request returned, which is the same value as the session_id of the quote that was booked.

The optional user_id narrows the read to the end-user the order was booked for. Supply it when you are serving a request on that user's behalf so an order that does not belong to that user returns ORDER_NOT_FOUND. Omit it and any order under your partner resolves.

Drive fulfillment UI from status and the timestamps. tracking_number and tracking_url are never populated on this flow — partner shipments are fulfilled by the vault vendor without a carrier label bought through this API — so treat both as always null rather than waiting for them to fill in.

Path Parameters
  • order_id
    Type: string Format: uuid
    required

    Shipping order id (the order_id UUID from /ship/request).

Query Parameters
  • user_id
    Type: string
    min length:  
    1

    Optional: your partner-defined user id the order was booked for. When sent, an order booked for a different user reads as not found.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/ship/order/{order_id}
curl https://api.phygitals.com/api/v1/ship/order/0194f0a5-3f60-7d4b-ae5f-8c1b7a4d9f23 \
  --header 'X-API-Key: YOUR_SECRET_TOKEN'
{
  "order_id": "0194f0a5-3f60-7d4b-ae5f-8c1b7a4d9f23",
  "status": "tracking_available",
  "carrier": "UPS",
  "service": "Ground",
  "tracking_number": "1Z999AA10123456784",
  "tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784",
  "amount": 20,
  "currency": "USD",
  "destination": {
    "name": "Jamie Collector",
    "line_1": "500 Terry A Francois Blvd",
    "line_2": "Suite 300",
    "city": "San Francisco",
    "region": "CA",
    "postal_code": "94158",
    "country": "US"
  },
  "items": [
    {
      "item_id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
      "name": "1999 Pokemon Base Set Pokemon Center #85 PSA 10",
      "image": "https://img.phygitals.com/cdn-cgi/image/width=1024,quality=85,format=auto,fit=scale-down/https%3A%2F%2Fimg.phygitals.com%2F6Ubfh7Nt7T9LvYqj1hQ1iHwSAVK5GvJUW2BLRieJC8bq-cropped",
      "back_image": null
    }
  ],
  "created_at": "2026-07-14T14:20:00.000Z",
  "shipped_at": "2026-07-14T18:45:00.000Z",
  "delivered_at": null,
  "error_message": null
}

Webhooks

Outbound events Phygitals POSTs to the URL you register in your partner dashboard. Each is a signed JSON envelope — { id, event, delivered_at, data } — where event names the type and data's shape is discriminated by it (see the individual events below).

What fires, and when. purchase.settled / purchase.failed when a POST /vm/buy/init does or doesn't settle; buyback.settled when a POST /vm/buyback credits your ledger; the shipping.* transitions as a physical shipment moves. These are pushes of the same facts the /vm/buy/status, /vm/buyback/status, and /ship/order endpoints report — subscribe instead of polling. In the dashboard you can subscribe to a namespace with a wildcard (purchase.*), to an exact type (buyback.settled), or to everything by registering no event filter at all, and send yourself a synthetic test delivery to check your receiver end-to-end before you go live. Registration is per mode: your sandbox endpoint receives sandbox events, your live endpoint live ones, and a sandbox event is never delivered to a live URL — so you can build and test your receiver before a single real dollar moves.

Registering / updating your endpoint. Manage delivery from your partner dashboard → Webhooks tab (/partners/<your-slug>/webhooks); it's owner/editor only — a viewer can see the registration but not change it. Set the HTTPS Endpoint URL and the subscribed events (comma-separated — e.g. shipping.*, purchase.*, buyback.settled, or leave blank to receive everything) and save; the form pre-populates with your current registration, so an update just edits it in place. Send test event POSTs a synthetic test payload synchronously — it ignores your subscription filter (so it reaches your endpoint even if you've only subscribed to, say, shipping.*) and reports the receiver's real HTTP response so you can verify your endpoint end-to-end. The signing secret is shown once — when you first register the endpoint, or when you explicitly rotate it; editing the endpoint leaves the secret unchanged. Rotating it invalidates the old secret, so update your verifier — and Remove endpoint stops deliveries.

A correct receiver (Node/Express — the same shape applies anywhere):

import crypto from "node:crypto";

// The RAW body is what was signed — parse AFTER verifying, never before.
app.post("/hooks/phygitals", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-Phygitals-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));

  // 1. Reject replays: refuse a timestamp outside a 5-minute window.
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return res.sendStatus(400);

  // 2. Recompute the HMAC over `${t}.${rawBody}` and compare in CONSTANT time.
  const expected = crypto
    .createHmac("sha256", process.env.PHYGITALS_WEBHOOK_SECRET)
    .update(`${parts.t}.${req.body.toString()}`)
    .digest("hex");
  const ok =
    parts.v1?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  const evt = JSON.parse(req.body.toString());

  // 3. Be idempotent: `evt.id` is STABLE across redeliveries of this event.
  //    Insert-if-absent (a unique index on the id) is the whole trick.
  if (!markProcessedIfNew(evt.id)) return res.sendStatus(200); // already handled

  // 4. Acknowledge NOW; do the slow work off the request.
  res.sendStatus(200);
  void handleAsync(evt);
});

Return a 5xx (not a 4xx) if you want a transient failure retried — see the delivery contract on any event below.

Webhook

Shipment state changed

Fires on every shipping.* lifecycle transition (queued → label_created → shipped → delivered, plus cancelled/failed) for a partner's physical shipment. event names the specific transition.

Verify the signature. Every delivery carries X-Phygitals-Signature: t=<unix-seconds>,v1=<hex>, where <hex> is hmac-sha256(<your-webhook-secret>, "<t>.<rawBody>") — the timestamp, a literal dot, then the raw request body. Recompute it over the RAW bytes (do not re-serialize the parsed JSON — key order and spacing would change the signature), compare in constant time, and reject a t older than your tolerance (5 minutes is typical) so a captured delivery can't be replayed at you. X-Phygitals-Delivery-Attempt carries the 1-based attempt number, for logging.

Be idempotent — this is the one thing integrations get wrong. Delivery is at-least-once: a timeout or a network blip between your 200 and our reading it means we retry an event you already processed. The envelope's id is the same on every redelivery of the same event, so record the ids you have handled and ignore a repeat. Do NOT dedupe on delivered_at (re-stamped per attempt) and do not rely on data.idempotency_key (it is null when the originating request sent no Idempotency-Key).

Acknowledge fast. Any 2xx means delivered. We wait 5s for your response, so acknowledge first and do your work asynchronously — a slow handler reads as a timeout and earns you a duplicate.

Retries. A timeout, a network error, a 5xx, or a 429/408/425 is retried with exponential backoff (30s, doubling, capped at 1800s) for up to 24h from when the event occurred, after which it is abandoned. Any OTHER 4xx is treated as a permanent client error and is NOT retried — so never return a 4xx for a transient problem you want us to resend; return a 5xx.

HTTPS only, and no redirects. Your endpoint must be a publicly reachable https:// URL. We do NOT follow redirects: point us straight at your handler.

No ordering guarantee. Events are delivered concurrently and retried independently, so a retried purchase.settled can arrive after a later event. Order your own processing by the timestamp inside data (e.g. settled_at), never by arrival.

Body·Shipping Webhook
required
application/json

The signed webhook envelope POSTed to your endpoint.

  • data
    Type: object · Shipping Event
    required

    Shipping event payload — a physical shipment changing state.

  • delivered_at
    Type: string Format: date-time
    required

    ISO-8601 timestamp of THIS delivery attempt. Re-stamped on every retry, so it differs between redeliveries of the same event — use id, not this, to dedupe.

  • event
    Type: string enum
    required

    Shipping lifecycle event type.

    values
    • shipping.queued
    • shipping.label_created
    • shipping.shipped
    • shipping.delivered
    • shipping.cancelled
    • shipping.failed
  • id
    Type: string
    required

    Unique id for this event, stable across redeliveries. Dedupe on this — see the Delivery section.

Responses
  • 200

    Acknowledged. Return any 2xx status; a non-2xx response (or a timeout) triggers a retry.

Request Example for postshipping
{
  "id": "",
  "event": "shipping.queued",
  "delivered_at": "",
  "data": {
    "type": "shipping.shipped",
    "order_id": "0194f0a5-3f60-7d4b-ae5f-8c1b7a4d9f23",
    "status": "shipped",
    "tracking_number": "1Z999AA10123456784",
    "tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784",
    "shipped_at": "2026-07-14T18:45:00.000Z",
    "delivered_at": null,
    "error_message": null,
    "carrier": "UPS",
    "service": "Ground",
    "amount": 20,
    "currency": "USD",
    "updated_at": "2026-07-14T18:45:00.000Z"
  }
}
No Body
Webhook

Partner buy settled

Fires when a partner buy completes successfully. Dedupe on the envelope's id.

Verify the signature. Every delivery carries X-Phygitals-Signature: t=<unix-seconds>,v1=<hex>, where <hex> is hmac-sha256(<your-webhook-secret>, "<t>.<rawBody>") — the timestamp, a literal dot, then the raw request body. Recompute it over the RAW bytes (do not re-serialize the parsed JSON — key order and spacing would change the signature), compare in constant time, and reject a t older than your tolerance (5 minutes is typical) so a captured delivery can't be replayed at you. X-Phygitals-Delivery-Attempt carries the 1-based attempt number, for logging.

Be idempotent — this is the one thing integrations get wrong. Delivery is at-least-once: a timeout or a network blip between your 200 and our reading it means we retry an event you already processed. The envelope's id is the same on every redelivery of the same event, so record the ids you have handled and ignore a repeat. Do NOT dedupe on delivered_at (re-stamped per attempt) and do not rely on data.idempotency_key (it is null when the originating request sent no Idempotency-Key).

Acknowledge fast. Any 2xx means delivered. We wait 5s for your response, so acknowledge first and do your work asynchronously — a slow handler reads as a timeout and earns you a duplicate.

Retries. A timeout, a network error, a 5xx, or a 429/408/425 is retried with exponential backoff (30s, doubling, capped at 1800s) for up to 24h from when the event occurred, after which it is abandoned. Any OTHER 4xx is treated as a permanent client error and is NOT retried — so never return a 4xx for a transient problem you want us to resend; return a 5xx.

HTTPS only, and no redirects. Your endpoint must be a publicly reachable https:// URL. We do NOT follow redirects: point us straight at your handler.

No ordering guarantee. Events are delivered concurrently and retried independently, so a retried purchase.settled can arrive after a later event. Order your own processing by the timestamp inside data (e.g. settled_at), never by arrival.

Body·Purchase Settled Webhook
required
application/json

The signed webhook envelope POSTed to your endpoint.

  • data
    Type: object · Purchase Settled Event
    required

    Purchase settled event payload — a partner buy that completed successfully.

  • delivered_at
    Type: string Format: date-time
    required

    ISO-8601 timestamp of THIS delivery attempt. Re-stamped on every retry, so it differs between redeliveries of the same event — use id, not this, to dedupe.

  • event
    const:  
    purchase.settled
    required
  • id
    Type: string
    required

    Unique id for this event, stable across redeliveries. Dedupe on this — see the Delivery section.

Responses
  • 200

    Acknowledged. Return any 2xx status; a non-2xx response (or a timeout) triggers a retry.

Request Example for postpurchase.settled
{
  "id": "",
  "event": "purchase.settled",
  "delivered_at": "",
  "data": {
    "type": "purchase.settled",
    "idempotency_key": "buy-rookie-2026-07-14-001",
    "session_id": "0194f0a2-7c3b-7a1e-9d2c-5b8e4f1a6c90",
    "amount": 25,
    "currency": "USD",
    "settled_at": "2026-07-14T13:03:46.300Z"
  }
}
No Body
Webhook

Partner buy failed

Fires when a partner buy fails to settle. Dedupe on the envelope's id.

Verify the signature. Every delivery carries X-Phygitals-Signature: t=<unix-seconds>,v1=<hex>, where <hex> is hmac-sha256(<your-webhook-secret>, "<t>.<rawBody>") — the timestamp, a literal dot, then the raw request body. Recompute it over the RAW bytes (do not re-serialize the parsed JSON — key order and spacing would change the signature), compare in constant time, and reject a t older than your tolerance (5 minutes is typical) so a captured delivery can't be replayed at you. X-Phygitals-Delivery-Attempt carries the 1-based attempt number, for logging.

Be idempotent — this is the one thing integrations get wrong. Delivery is at-least-once: a timeout or a network blip between your 200 and our reading it means we retry an event you already processed. The envelope's id is the same on every redelivery of the same event, so record the ids you have handled and ignore a repeat. Do NOT dedupe on delivered_at (re-stamped per attempt) and do not rely on data.idempotency_key (it is null when the originating request sent no Idempotency-Key).

Acknowledge fast. Any 2xx means delivered. We wait 5s for your response, so acknowledge first and do your work asynchronously — a slow handler reads as a timeout and earns you a duplicate.

Retries. A timeout, a network error, a 5xx, or a 429/408/425 is retried with exponential backoff (30s, doubling, capped at 1800s) for up to 24h from when the event occurred, after which it is abandoned. Any OTHER 4xx is treated as a permanent client error and is NOT retried — so never return a 4xx for a transient problem you want us to resend; return a 5xx.

HTTPS only, and no redirects. Your endpoint must be a publicly reachable https:// URL. We do NOT follow redirects: point us straight at your handler.

No ordering guarantee. Events are delivered concurrently and retried independently, so a retried purchase.settled can arrive after a later event. Order your own processing by the timestamp inside data (e.g. settled_at), never by arrival.

Body·Purchase Failed Webhook
required
application/json

The signed webhook envelope POSTed to your endpoint.

  • data
    Type: object · Purchase Failed Event
    required

    Purchase failed event payload — a partner buy that did not settle.

  • delivered_at
    Type: string Format: date-time
    required

    ISO-8601 timestamp of THIS delivery attempt. Re-stamped on every retry, so it differs between redeliveries of the same event — use id, not this, to dedupe.

  • event
    const:  
    purchase.failed
    required
  • id
    Type: string
    required

    Unique id for this event, stable across redeliveries. Dedupe on this — see the Delivery section.

Responses
  • 200

    Acknowledged. Return any 2xx status; a non-2xx response (or a timeout) triggers a retry.

Request Example for postpurchase.failed
{
  "id": "",
  "event": "purchase.failed",
  "delivered_at": "",
  "data": {
    "type": "purchase.failed",
    "idempotency_key": "buy-rookie-2026-07-14-001",
    "failure_reason": "INSUFFICIENT_BALANCE",
    "amount": 25,
    "currency": "USD",
    "failed_at": "2026-07-14T13:03:46.300Z"
  }
}
No Body
Webhook

Partner buyback settled

Fires when a partner buyback settles and credits the partner ledger. Dedupe on the envelope's id.

Verify the signature. Every delivery carries X-Phygitals-Signature: t=<unix-seconds>,v1=<hex>, where <hex> is hmac-sha256(<your-webhook-secret>, "<t>.<rawBody>") — the timestamp, a literal dot, then the raw request body. Recompute it over the RAW bytes (do not re-serialize the parsed JSON — key order and spacing would change the signature), compare in constant time, and reject a t older than your tolerance (5 minutes is typical) so a captured delivery can't be replayed at you. X-Phygitals-Delivery-Attempt carries the 1-based attempt number, for logging.

Be idempotent — this is the one thing integrations get wrong. Delivery is at-least-once: a timeout or a network blip between your 200 and our reading it means we retry an event you already processed. The envelope's id is the same on every redelivery of the same event, so record the ids you have handled and ignore a repeat. Do NOT dedupe on delivered_at (re-stamped per attempt) and do not rely on data.idempotency_key (it is null when the originating request sent no Idempotency-Key).

Acknowledge fast. Any 2xx means delivered. We wait 5s for your response, so acknowledge first and do your work asynchronously — a slow handler reads as a timeout and earns you a duplicate.

Retries. A timeout, a network error, a 5xx, or a 429/408/425 is retried with exponential backoff (30s, doubling, capped at 1800s) for up to 24h from when the event occurred, after which it is abandoned. Any OTHER 4xx is treated as a permanent client error and is NOT retried — so never return a 4xx for a transient problem you want us to resend; return a 5xx.

HTTPS only, and no redirects. Your endpoint must be a publicly reachable https:// URL. We do NOT follow redirects: point us straight at your handler.

No ordering guarantee. Events are delivered concurrently and retried independently, so a retried purchase.settled can arrive after a later event. Order your own processing by the timestamp inside data (e.g. settled_at), never by arrival.

Body·Buyback Settled Webhook
required
application/json

The signed webhook envelope POSTed to your endpoint.

  • data
    Type: object · Buyback Settled Event
    required

    Buyback settled event payload — an item sold back, crediting the partner ledger.

  • delivered_at
    Type: string Format: date-time
    required

    ISO-8601 timestamp of THIS delivery attempt. Re-stamped on every retry, so it differs between redeliveries of the same event — use id, not this, to dedupe.

  • event
    const:  
    buyback.settled
    required
  • id
    Type: string
    required

    Unique id for this event, stable across redeliveries. Dedupe on this — see the Delivery section.

Responses
  • 200

    Acknowledged. Return any 2xx status; a non-2xx response (or a timeout) triggers a retry.

Request Example for postbuyback.settled
{
  "id": "",
  "event": "buyback.settled",
  "delivered_at": "",
  "data": {
    "type": "buyback.settled",
    "idempotency_key": "buyback-rookie-2026-07-14-001",
    "buyback_id": "0194f0a3-1d4e-7b2f-8c3d-6a9f5e2b7d01",
    "item_id": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK",
    "amount": 361.39,
    "currency": "USD",
    "settled_at": "2026-07-14T14:10:00.000Z"
  }
}
No Body

Models

A shipping address — used as the destination on a ship/quote and echoed back as the address on a ship/order.

  • city
    Type: string
    min length:  
    1
    required

    City.

  • country
    Type: string
    min length:  
    1
    required

    Destination country (ISO-2, e.g. "US"). The ISO-2 requirement is enforced when you submit a shipment, not by this schema.

  • line_1
    Type: string
    min length:  
    1
    required

    Street address (house number + street).

  • name
    Type: string
    min length:  
    1
    required

    Recipient full name.

  • postal_code
    Type: string
    required

    Postal / ZIP code ("" when not applicable).

  • region
    Type: string
    required

    State / province / region ("" when not applicable).

  • email
    Type: string

    Recipient email (optional).

  • line_2
    Type: string

    Apartment / suite / unit (optional).

  • phoneNumber
    Type: string

    Recipient phone number (optional).

A purchasable shipping rate returned by POST /ship/quote / vault.claim.priceEstimate.

  • carrier
    Type: string
    required

    Carrier / fulfiller name (e.g. Phygitals for graded vault rates).

  • estimated_days_max
    Type: number
    required

    Upper bound of the delivery estimate, in days.

  • estimated_days_min
    Type: number
    required

    Lower bound of the delivery estimate, in days.

  • estimated_delivery
    Type: string
    required

    Free-form delivery-estimate text.

  • id
    Type: string
    required

    Rate id — pass to POST /ship/request to book this option.

  • notes
    Type: string
    required

    Additional notes about this rate.

  • service
    Type: string
    required

    Service level (e.g. Graded Vault Fulfillment).

  • shipping_cost
    Type: number
    required

    Shipping cost in USD.

  • total_cost
    Type: number
    required

    Total charged in USD (withdrawal fees + shipping).

  • withdrawal_fees
    Type: number
    required

    Vault withdrawal fees in USD.

  • name
    Type: string

    Human-readable rate summary.

A shipping order and its fulfillment state (GET /ship/order/{order_id}).

  • amount
    Type: number
    required

    Charged shipping cost.

  • carrier
    Type: string
    required

    Booked carrier.

  • created_at
    Type: string Pattern: ^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d{1,9})?)?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)(\[.+\])?$Format: date-time
    required

    When the order was created (ISO 8601).

  • currency
    Type: string
    required

    Currency of amount (e.g. USD).

  • delivered_at
    Type: string Pattern: ^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d{1,9})?)?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)(\[.+\])?$Format: date-timenullable
    required

    When the order was delivered (ISO 8601), or null.

    An ISO 8601 instant string with a required UTC offset (e.g. 2023-01-15T13:45:30Z)

  • destination
    Type: object ·
    required

    A shipping address — used as the destination on a ship/quote and echoed back as the address on a ship/order.

  • error_message
    Type: string nullable
    required

    Failure reason when status is "failed", else null.

  • items
    Type: array object[]
    required

    Items included in this shipment.

  • order_id
    Type: string
    required

    Shipping order id.

  • service
    Type: string
    required

    Booked service level.

  • shipped_at
    Type: string Pattern: ^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d{1,9})?)?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)(\[.+\])?$Format: date-timenullable
    required

    When the order shipped (ISO 8601), or null.

    An ISO 8601 instant string with a required UTC offset (e.g. 2023-01-15T13:45:30Z)

  • status
    Type: string enum
    required

    Current fulfillment status.

    values
    • processing
    • transit
    • tracking_available
    • delivered
    • cancelled
    • failed
  • tracking_number
    Type: string nullable
    required

    Carrier tracking number once shipped, else null.

  • tracking_url
    Type: string Format: urinullable
    required

    Carrier tracking URL once shipped, else null.

A high-value "chase" card highlighted on a pack.

  • fmv
    Type: number
    required

    Fair market value in USD, rounded to cents.

  • id
    Type: string
    required

    Card item id.

  • image
    Type: string
    required

    Front image URL (CDN-cropped).

  • name
    Type: string
    required

    Card display name.

One inventory item — a pack's backing row, or one item of the pack-eligible supply pool.

  • category
    Type: string nullable
    required

    The item's OWN category (e.g. pokemon), or null when it carries none — this is what the repeatable category filter on GET /inventory/available matches against. Not to be confused with a pack's categories, which are a property of the pack rather than of any item in it.

  • fmv
    Type: number
    required

    Fair market value in USD, rounded to cents. On a pack's backing inventory this is the per-item value its EV is computed from; in the supply pool it is what the item would contribute to a pack. Same number, same column — the two routes just reach it from different directions.

  • id
    Type: string
    required

    Item id (the EbayListing id) — pass to GET /card/{item_id} for full detail.

  • image
    Type: string
    required

    Front image URL (CDN-cropped).

  • name
    Type: string
    required

    Card display name.

Pagination metadata for a paginated partner-API list response.

  • count
    Type: integer
    min:  
    -9007199254740991
    max:  
    9007199254740991
    required

    Total number of records matching the request across all pages (not just this page's size).

  • has_more
    Type: boolean
    required

    True when a further page exists — fetch it by passing next_cursor back as cursor.

  • limit
    Type: integer
    min:  
    -9007199254740991
    max:  
    9007199254740991
    required

    The effective page size applied to this request (echoed back).

  • next_cursor
    Type: string nullable
    required

    Opaque keyset cursor for the next page — pass it back as cursor. Null on the last page.

A single trait on a card's metadata.

  • trait_type
    Type: string
    required

    Attribute name, e.g. "Set" or "Grade".

  • value
    Type: string
    required

    Attribute value as a display string.

Card metadata block returned for every partner card/NFT.

  • attributes
    Type: array object[] ·
    required

    Trait list shown on the card detail view.

    A single trait on a card's metadata.

  • back_image
    Type: string nullable
    required

    Back image URL, or null when the card has no back asset.

  • image
    Type: string
    required

    Front image URL (CDN-cropped).

  • name
    Type: string
    required

    Display name of the card.

An NFT/item as returned by the buy, buy-status and inventory endpoints.

  • buyback_price
    Type: number
    required

    Current buyback price in USD (FMV × pack buyback percent).

  • collection_address
    Type: string nullable
    required

    On-chain collection address, or null.

  • content
    Type: object
    required

    Nested metadata, matching the marketplace NFT envelope.

  • id
    Type: string
    required

    Stable item identifier for this NFT.

  • mint_address
    Type: string nullable
    required

    On-chain mint address, or null for off-chain items.

  • token_standard
    Type: string nullable
    required

    Token standard (e.g. NFT / pNFT / compressed), or null.

  • type
    Type: string
    required

    Item type/kind discriminator.

A purchasable pack as returned by GET /vm/available.

  • buyback_percent
    Type: number
    required

    Fraction of FMV paid on buyback (0–1).

  • categories
    Type: array string[]
    required

    All categories assigned to the pack.

  • category
    Type: string nullable
    required

    Primary category.

  • chase
    Type: array object[] ·
    required

    Always empty on this listing — use GET /vm/chase/{slug} for top hits.

    A high-value "chase" card highlighted on a pack.

  • description
    Type: string nullable
    required

    Pack description.

  • enable
    Type: boolean
    required

    Whether the pack is enabled/visible.

  • ev
    Type: number
    required

    Latest computed expected value in USD. Exact — comparable against the EV band below.

  • ev_updated_at
    Type: string Pattern: ^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d{1,9})?)?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)(\[.+\])?$Format: date-timenullable
    required

    When ev was last recomputed (ISO 8601), or null.

    An ISO 8601 instant string with a required UTC offset (e.g. 2023-01-15T13:45:30Z)

  • id
    Type: string
    required

    Pack id — Solana mint for CORE packs; short id (e.g. "13") for EBAY packs.

  • in_stock
    Type: boolean
    required

    True when the pack currently has pullable inventory.

  • last_pull
    Type: string Pattern: ^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d{1,9})?)?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)(\[.+\])?$Format: date-timenullable
    required

    Timestamp of the most recent pull (ISO 8601), or null.

    An ISO 8601 instant string with a required UTC offset (e.g. 2023-01-15T13:45:30Z)

  • max_ev
    Type: number
    required

    Upper bound of the configured EV band, in USD. Exact — the stock gate compares against it.

  • max_per_mint
    Type: number
    required

    Maximum packs purchasable in a single mint.

  • min_ev
    Type: number
    required

    Lower bound of the configured EV band, in USD. Exact — the stock gate compares against it.

  • mint_price
    Type: string
    required

    Price to mint one pack, in USD (decimal string).

  • name
    Type: string nullable
    required

    Pack display name.

  • num_pulls_7d
    Type: number
    required

    Number of pulls in the last 7 days.

  • platform
    Type: string
    required

    Owning partner platform key.

  • rarity_distribution
    Type: array object[] | null · Rarity Distributionnullable
    required

    Stored rarity-tier configuration, or null.

    Stored rarity-tier configuration — the pack's FMV ranges and their pull weights.

  • repack
    Type: boolean
    required

    True when this pack is a repack of previously-pulled inventory.

  • rewards_amounts
    Type: array number[]
    required

    Deprecated pack token-bonus field — always empty.

  • rewards_decimals
    Type: array number[]
    required

    Deprecated pack token-bonus field — always empty.

  • rewards_mint_addresses
    Type: array string[]
    required

    Deprecated pack token-bonus field — always empty.

  • rewards_symbols
    Type: array string[]
    required

    Deprecated pack token-bonus field — always empty.

  • sellback_rewards_amounts
    Type: array number[]
    required

    Deprecated pack token-bonus field — always empty.

  • slug
    Type: string nullable
    required

    URL slug for the pack, or null.

  • type
    Type: string enum
    required

    Pack type.

    values
    • CORE
    • EBAY
  • claw_image_url
    Type: string nullable

    Pack artwork URL, or null.

  • creator_profile
    Type: object

    Public profile of the pack creator, when available.

  • pack_managers
    Type: array object[]

    Users who manage this pack.

  • variant_of
    Type: string nullable

    Parent pack id when this is a variant, else null.

  • variants
    Type: array object[] · Pack[]

    Sibling variant packs, when this pack has variants.

    Full collections row for a partner-owned pack (admin VM endpoints).

Card detail resolved by item id, NFT address or slug (GET /card/{item_id}).

  • back_image
    Type: string nullable
    required

    Back image URL, or null.

  • fmv
    Type: number
    required

    Fair market value in USD, rounded to cents.

  • id
    Type: string
    required

    Card item id.

  • image
    Type: string
    required

    Front image URL (CDN-cropped).

  • metadata
    Type: object · Card Metadata
    required

    Full card metadata block.

  • name
    Type: string
    required

    Card display name.

A recent pull across the caller's packs (GET /vm/recent-pulls).

  • buyback_price
    Type: number
    required

    Buyback price offered for the pulled card, in USD. Exact — derived from the unrounded FMV, so it will not equal value times the pack's buyback percent.

  • claw_id
    Type: string
    required

    Id of the pack the card was pulled from.

  • claw_slug
    Type: string nullable
    required

    Slug of the pack, or null.

  • created_at
    Type: string Pattern: ^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d{1,9})?)?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)(\[.+\])?$Format: date-time
    required

    When the pull happened (ISO 8601).

  • id
    Type: string
    required

    Pull/activity id.

  • metadata
    Type: object · Card Metadata
    required

    Metadata of the pulled card.

  • value
    Type: number
    required

    Pulled card's FMV in USD, rounded to cents.

The stored purchase session — present only when status is fulfilled.

  • nfts
    Type: array object[] ·
    required

    Cards picked in the session.

    An NFT/item as returned by the buy, buy-status and inventory endpoints.

  • public_id
    Type: string
    required

    Public-facing session identifier.

  • session_id
    Type: string
    required

    Purchase session id.

  • tx_hash
    Type: string
    required

    Transaction hash (synthetic for off-chain/sandbox buys).

  • user_id
    Type: string
    required

    Your partner-defined user id the session belongs to.