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 (called a claw internally — 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 — id, name, front/back image, and fmv — 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.

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 plaintext 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 (it's an OR):

  • 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 <privy-token> — a Privy access token for a user whitelisted to your partner platform. A Privy session always resolves to sandbox; 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. This reference is served on it too: the docs you're reading are at /api/v1/, and the raw OpenAPI document at /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) /api/v1 Prepaid partner ledger + real vaulted inventory
Production (long form) /api/partner/v1 Identical to /api/v1 — same handler, same mode
Sandbox /api/partner/sandbox/v1 Fully simulated (no ledger / vault / chain)
Legacy /_/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 Privy session) is rejected with 403 rather than silently escalated to live data. /api/partner/v1 is a long-form alias of /api/v1 — the same handler in the same forced live mode — kept for integrations that already call it; prefer the short form. The legacy /_/api/v1 prefix also keeps working and follows the key's own mode, but new integrations should use /api/v1 (or /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 — 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.
  • Failed attempts don't stick. A key whose only prior attempt failed is not blocked: the retry executes fresh, since nothing was charged.
  • 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.

Sandbox

The sandbox runs the full API against an in-memory 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 in-memory and ephemeral. Sandbox sessions, simulated inventory, buybacks, and shipping orders live in the API process's memory and are wiped on restart or deploy; separate instances don't share them. 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.)
  • No real inventory reservation. Live draws and reserves physical inventory rows under a database lock so a card is never handed to two buyers; the sandbox simulates the draw in memory with no row-locking, so it is not a faithful test of two buyers racing for the same card.
  • Weaker buy/init deduplication. The Idempotency-Key replay cache behaves identically in both modes (a retry with the same key + body replays the first response). But the extra 409 DUPLICATE_REQUEST guard against two concurrent same-key buys is live-only — in sandbox, two simultaneous in-flight buys with the same key can both succeed. Test retry replay in sandbox; test hard duplicate rejection with a live key.
  • Sellback pricing is frozen. A sandbox POST /vm/buyback pays the buyback_price fixed on the item at buy time; live prices from the item's current FMV. Don't assert on exact sandbox payouts.
  • Shipping orders don't progress. A sandbox POST /ship/request creates an order and leaves it there — tracking_number, tracking_url, shipped_at, and delivered_at stay null for the life of the process, since no carrier actually moves the card.
  • 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 (internally a "claw" — 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

Packs the caller can browse. With platform-browse enabled, only this partner's packs (a platform query is ignored). Otherwise defaults to mainnet (or the platform override).

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 /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

A pack's chase cards — its headline high-value "top hits", each with id, name, front/back image, and fmv. Use them to render a "what could I pull?" preview before a buyer purchases; they're the best-case pulls the pack can yield, not a guarantee any given buy lands one.

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 /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

Recent pulls across packs (live + sandbox). Filter with claw_ids; cap with limit.

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 /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

The live backing inventory for a pack — every item currently in the pool that backs the pack's EV, each with its per-item fmv. This is the item-level breakdown behind the EV band GET /vm/available reports: the SAME set of items (same availability + rarity-tier eligibility) the EV is computed from. Enumerates the whole backing set in a stable order in the standard cursor-native page envelope (data + pagination) — read the first page without a cursor, then follow pagination.next_cursor (equivalently until pagination.has_more is false). 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 or its FMV).

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 /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
    },
    {
      "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
    }
  ],
  "pagination": {
    "limit": 50,
    "count": 128,
    "has_more": true,
    "next_cursor": "6miVCvzYMeZbfxYA1g6aAs74wWpAfwq8dswiCrS1aVLK"
  }
}

Items

Look up an individual item and a user's inventory.

List a user's items

The current (unsold) items held by user user_id.

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 /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

Item detail resolved by EbayListing id, NFT address, or slug (item_id).

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 /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"
      }
    ]
  }
}

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).

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.

  • 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 /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 /vm/buy/init attempt by session_id (the platform-minted id returned by /vm/buy/init) and/or idempotency_key (the Idempotency-Key — header or body — you sent with the original call, scoped to your partner). At least one is required; supplying neither is a validation error. By session_id: resolves fulfilled (see result) or not_found (unknown, or owned by a different partner). By idempotency_key: resolves fulfilled, failed (see failure_reason), or unknown_key (no attempt on record for this key). 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 that names 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 /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

Your buy sessions, newest first — the historical companion to the single-session lookups below, and the replacement for POST /vm/buy/status. Each row is either fulfilled (a completed /vm/buy/init, carrying the same result session GET /vm/sessions/{session_id} returns) or failed (a recorded failed attempt, carrying a failure_reason; only attempts made with an Idempotency-Key are recorded). Filter by status (fulfilled or failed) and/or by user_id (your partner-defined end-user id, the same one you passed at buy time); both together AND. Cursor-paginated in the standard envelope (data + pagination): read the first page without a cursor, then follow pagination.next_cursor (equivalently until pagination.has_more is false). 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 /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

Looks up a /vm/buy/init session by its session_id (the platform-minted id returned by /vm/buy/init), scoped to your partner. The GET replacement for POST /vm/buy/status's session_id path, addressed by URL so you don't have to construct a request body. Returns 200 with the fulfilled session (see result); returns 404 SESSION_NOT_FOUND when the id is unknown or owned by a different partner (never leaked as a different status).

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 /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

Looks up a /vm/buy/init attempt by the idempotency_key (the Idempotency-Key — header or body — you sent with the original call), scoped to your partner. The GET replacement for POST /vm/buy/status's idempotency_key path, addressed by URL so you don't have to construct a request body. Returns 200 with the record — fulfilled (see result) or failed (see failure_reason) — and 404 SESSION_NOT_FOUND when no attempt is on record for this key.

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 /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 /vm/buyback by buyback_id (the platform-minted id returned by /vm/buyback) and/or idempotency_key (the Idempotency-Key — header or body — you sent with the original call, scoped to your partner). At least one is required; supplying neither is a validation error. By buyback_id: resolves credited (see result) or not_found (unknown, or belonging to a different partner). By idempotency_key: resolves credited or unknown_key (no buyback on record for this key). Sending BOTH is allowed and cross-checks them: buyback_id is the lookup key and idempotency_key must be the key that buyback was credited under, so a pair that names two different buybacks resolves not_found. An item_id is deliberately NOT a key: a sold-back card returns to the shared pool and can be bought and sold back again, so one item can have 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 /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

Your credited buybacks, newest first. Filter by item_id — an item can be bought and sold back many times (a sold-back card returns to the shared pool and can be re-bought), so this legitimately returns MULTIPLE buybacks — and/or by user_id (your partner-defined end-user id, the same one you passed at buy time); both together AND. Cursor-paginated: pass the previous page's next_cursor back as cursor, and stop once it comes back null. 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 /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

Looks up a /vm/buyback by its buyback_id (the platform-minted id returned by /vm/buyback), scoped to your partner. A convenience wrapper over POST /vm/buyback/status's buyback_id path, addressed by URL so you don't have to construct a request body. Resolves credited (see result) or not_found (unknown, or belonging to a different partner).

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 /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

Looks up a /vm/buyback by the idempotency_key (the Idempotency-Key — header or body — you sent with the original /vm/buyback call), scoped to your partner. A convenience wrapper over POST /vm/buyback/status's idempotency_key path, addressed by URL so you don't have to construct a request body. Resolves credited (see result) or unknown_key (no buyback on record for this key). If you reused a key across buybacks, resolves the most recent one.

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 /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 item item_id back at its buyback price, restocking it to the pool. Live for live keys; simulated for sandbox keys.

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 /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"
}

Shipping

Quote shipping for items

Validates the destination address and returns available shipping rates for item_ids.

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 /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 session created by POST /ship/quote (e.g. to re-price a chosen rate server-side before booking). Errors QUOTE_EXPIRED once the session lapses.

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 '/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

Turns a quote_id from /ship/quote into a shipping order. The order opens without any carrier-label spend; fulfillment is handled by the vault vendor.

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 /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

Every shipping order booked for user_id, newest first.

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 '/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

Current status of shipping order order_id.

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 /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 misconfiguration on your end (bad route, rejected body) 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 an https:// URL on a publicly routable host — we reject http:// at registration and refuse to deliver to private, loopback, link-local, or metadata addresses. We do NOT follow redirects: point us straight at your handler, because a 3xx response is treated as a permanent misconfiguration (like any other non-retryable 4xx) and the delivery is not retried.

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 misconfiguration on your end (bad route, rejected body) 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 an https:// URL on a publicly routable host — we reject http:// at registration and refuse to deliver to private, loopback, link-local, or metadata addresses. We do NOT follow redirects: point us straight at your handler, because a 3xx response is treated as a permanent misconfiguration (like any other non-retryable 4xx) and the delivery is not retried.

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 misconfiguration on your end (bad route, rejected body) 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 an https:// URL on a publicly routable host — we reject http:// at registration and refuse to deliver to private, loopback, link-local, or metadata addresses. We do NOT follow redirects: point us straight at your handler, because a 3xx response is treated as a permanent misconfiguration (like any other non-retryable 4xx) and the delivery is not retried.

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 misconfiguration on your end (bad route, rejected body) 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 an https:// URL on a publicly routable host — we reject http:// at registration and refuse to deliver to private, loopback, link-local, or metadata addresses. We do NOT follow redirects: point us straight at your handler, because a 3xx response is treated as a permanent misconfiguration (like any other non-retryable 4xx) and the delivery is not retried.

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.

  • id
    Type: string
    required

    Card item id.

  • image
    Type: string
    required

    Front image URL (CDN-cropped).

  • name
    Type: string
    required

    Card display name.

One item currently backing the pack's EV, with its per-item FMV.

  • fmv
    Type: number
    required

    Fair market value in USD — the per-item value backing the pack's EV.

  • id
    Type: string
    required

    Backing item id (the EbayListing id).

  • 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.

  • 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.

  • 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.

  • 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

    Reward token amounts in base units, as numbers. NOTE: large values may lose precision (legacy vm.available behavior — see packWireSchema for the string form).

  • rewards_decimals
    Type: array number[]
    required

    Reward token decimals, index-aligned with the reward arrays.

  • rewards_mint_addresses
    Type: array string[]
    required

    Reward token mint addresses, index-aligned with the reward arrays.

  • rewards_symbols
    Type: array string[]
    required

    Reward token symbols, index-aligned with the reward arrays.

  • sellback_rewards_amounts
    Type: array number[]
    required

    Reward amounts granted on sellback, in base units as numbers.

  • 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.

  • tier_counts
    Type: object

    Map of rarity-tier id → count of cards currently in that tier.

  • 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.

  • 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.

  • 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.

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.