# Slab Dynasty Partner API — 2026-07

A REST API for listing, pricing and fulfilling graded trading cards. It follows
Shopify's Admin REST conventions closely — same resource envelopes, same
cursor-based pagination, same leaky-bucket rate limiting — so an existing
Shopify integration ports with a host swap and a header rename.

- Base URL: `https://api.slabdynasty.com`
- This document covers API version `2026-07`. Pin a version explicitly in every request path.
- Machine-readable spec: https://developers.slabdynasty.com/openapi.json
- Human docs: https://developers.slabdynasty.com/docs · Changelog: https://developers.slabdynasty.com/changelog
- Access: keys are issued from an approved seller account at slabdynasty.com under Profile → Developers. Request early access at https://developers.slabdynasty.com/apply.

## Getting started

Create an API key in the dashboard (Profile → Developers → Create key), export
it, and verify it end to end with `ping`:

```bash
export SLAB_API_KEY="slab_live_..."   # the token shown once at creation

curl https://api.slabdynasty.com/2026-07/ping \
  -H "X-SlabDynasty-Access-Token: $SLAB_API_KEY"
```

A 200 proves the token, the version segment, the rate limiter and your
seller's eligibility all line up. A 401 means the token is wrong or revoked; a
403 means the seller cannot currently transact.

## Authentication

Pass the key in either header. Never in the query string — a request carrying
`?access_token=` is rejected with 401 before anything else happens.

```
X-SlabDynasty-Access-Token: slab_live_...

# or, equivalently
Authorization: Bearer slab_live_...
```

Keys are minted per environment and the environment is baked into the token
(`slab_live_…` / `slab_test_…`). Responses carry `X-SlabDynasty-Api-Mode`.
Test mode is read-only on production: write endpoints reject test keys with
`403 test_mode_forbidden`. On the sandbox deployment test keys can write, and
records they create are tagged `isTest`. Test-mode reads return only those
`isTest` records rather than live data.

## Versioning

Versions are dated calendar quarters in the path (`https://api.slabdynasty.com/2026-07/…`).
There is no "latest" alias — pin explicitly. An unrecognised version returns
404 `unknown_api_version` with the supported list in the body. Every version
is supported for at least 12 months from release; once
deprecated, its responses carry `Sunset` (RFC 8594) and `Deprecation`
(RFC 9745) headers plus a `Link; rel="sunset"` pointing at that version's docs.

| Version | Status | Released | Support | Summary |
| --- | --- | --- | --- | --- |
| `2026-07` | Current | 2026-07-01 | supported through at least 2027-07-01 | First public release — authentication, the read path and cursor paging. |

## Rate limits

A leaky bucket per credential: 40 requests of
burst capacity refilling at 2 per
second in live mode, 20 /
1 in test mode. Every response
carries `X-SlabDynasty-Api-Call-Limit: used/capacity`. Exceeding it returns 429
with a `Retry-After` header — honour it, because the bucket is charged before
the scope check, so a hot retry loop keeps itself throttled. Request bodies are
capped at 1,048,576 bytes
(413 `body_too_large`).

## Pagination

Collections use keyset cursors carried entirely in the `Link` response
header — there is no offset paging and no paging fields in the body. Walk a
collection by following `rel="next"` until it stops appearing:

```bash
curl -sD headers.txt "https://api.slabdynasty.com/2026-07/listings.json?limit=50" \
  -H "X-SlabDynasty-Access-Token: $SLAB_API_KEY"

# headers.txt now contains:
#   Link: <https://api.slabdynasty.com/2026-07/listings.json?limit=50&page_info=eyJ...>; rel="next"
# Request that URL verbatim for the next page. No Link header means no more pages.
```

Rules: send `page_info` exactly as issued; changing a filter mid-walk is
rejected with 400 `page_info_filter_mismatch`, and changing the sort with
400 `page_info_sort_mismatch`. Cursors expire with the data they point at,
so restart from page one rather than storing them long-term.

## Scopes

A key carries an explicit scope list chosen at creation. A `write_` scope
implies its `read_` counterpart. Calls missing the required scope return
403 `insufficient_scope`.

| Scope | Grants | Detail |
| --- | --- | --- |
| `read_listings` | View listings | See your listings, prices and status. |
| `write_listings` | Manage listings | Create, edit, end and relist. Can reprice or end every listing you have. |
| `read_inventory` | View inventory | See your cards and their availability. |
| `write_inventory` | Manage inventory | Mark cards available or unavailable. Can deactivate every listing you have. |
| `read_orders` | View orders | See buyer orders, including partially redacted shipping details. |
| `write_orders` | Manage orders | Cancel orders and request buyer cancellations. |
| `read_fulfillments` | View shipments | See shipping status and tracking for your orders. |
| `write_fulfillments` | Manage shipments | Mark orders shipped to our hub, attach tracking and buy labels. |
| `read_catalog` | Look up cards | Look up PSA certs, card metadata and price recommendations. |
| `read_seller` | View seller status | See your seller tier, limits, fees and payout status. |

## Errors

Error envelopes match Shopify's two shapes — a bare string for auth/not-found,
a field map for validation — plus an additive `slab_error` object carrying a
stable machine-readable `code` and the `request_id` for support. **Branch
on the code, never on the message.**

```json
{
  "errors": { "price": ["must be greater than 0"] },
  "slab_error": {
    "code": "validation_error",
    "request_id": "req_01J8ZC4Q7N2M5V8W1X3Y6Z9B0C"
  }
}
```

| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `invalid_api_key` | The token is missing, malformed, revoked, expired, or was sent in the query string. Every authentication failure returns this one code — distinguishing them would let a caller probe which keys exist. Send the key in X-SlabDynasty-Access-Token or Authorization: Bearer. If it was working yesterday, check whether it was rotated or revoked in the dashboard. |
| 403 | `insufficient_scope` | The key is valid but was not granted the scope this endpoint requires. Edit the key's scopes in the dashboard, or create a key with the required scope. The required scope is listed on each endpoint in the reference. |
| 403 | `test_mode_forbidden` | A test-mode key called a write endpoint on production, where test mode is read-only. Use a live key for writes, or run the write against the sandbox deployment, where test keys can write. |
| 403 | `seller_not_approved` | The seller account behind the key has not completed approval, so it cannot transact yet. Finish seller onboarding at slabdynasty.com/profile/settings/seller-setup. |
| 403 | `seller_frozen` | The seller account behind the key is temporarily frozen. Contact support with the request_id. |
| 403 | `seller_banned` | The seller account behind the key is not permitted to use the Partner API. Contact support. |
| 403 | `seller_not_found` | The account behind this API key no longer exists. Create a key from an active seller account. |
| 403 | `seller_not_eligible` | The seller account behind the key cannot use the Partner API right now. Contact support with the request_id. |
| 403 | `seller_paused` | Partner API access for this seller account is temporarily paused by Slab Dynasty. Keys are intact and access resumes automatically when the pause is lifted. Pause and retry with backoff, or contact support with the request_id. Do not rotate or re-create keys — they are not the problem. |
| 404 | `not_found` | No such resource. A resource belonging to another seller returns the same 404 as one that does not exist. Check the id. Ids come from list endpoints and are opaque strings. |
| 404 | `unknown_api_version` | The version segment in the path is not a supported API version. The response body lists every supported version with its status and sunset date. Pin a version from slab_error.supported_versions. |
| 400 | `invalid_page_info` | The page_info cursor is malformed or was not issued by this API. Send the cursor exactly as it appeared in the previous response's Link header. Never construct or edit one. |
| 400 | `page_info_sort_mismatch` | The page_info cursor was issued under a different sort order. Restart the walk from page one after changing the sort. |
| 400 | `page_info_resource_mismatch` | The page_info cursor was issued by a different endpoint. Cursors are only valid on the endpoint that issued them. Walk each collection with its own cursors from its own Link headers. |
| 400 | `page_info_filter_mismatch` | A filter changed mid-walk. Cursors embed a hash of the filter set, so changing a filter between pages is rejected rather than silently skipping rows. slab_error.changed_params names the offending parameters. Restart the walk from page one after changing any filter. |
| 422 | `validation_error` | One or more field values are invalid. The errors object is keyed by field, matching Shopify's 422 shape. Fix the named fields and retry. |
| 422 | `invalid_json` | The request body is not valid JSON. Send a JSON body and a matching Content-Type. |
| 413 | `body_too_large` | The request body exceeds the limit. slab_error.max_bytes carries the exact cap. Split the request. No documented endpoint needs a body near the cap. |
| 403 | `listing_write_forbidden` | The seller account cannot perform this listing write right now — paused, frozen, restricted, capped, or the card is blocklisted. The message carries the specific rule. Resolve the named account issue in the dashboard, or contact support with the request_id. |
| 409 | `listing_write_conflict` | The write conflicts with current marketplace state: the card is already actively listed, reserved by a pending order, or the listing has sold. Re-read the resource (inventory_levels.json shows why a card is unavailable) and retry against current state. |
| 422 | `card_not_listable` | The card itself cannot be listed — sold, archived, shipped, in a trade, or awaiting submission review. slab_error.reason carries the same reason code inventory_levels.json publishes. Check the card's inventory_level for the reason. Most states resolve on their own; archived cards can be unarchived in the dashboard. |
| 422 | `cert_not_found` | No grader recognizes this cert number (with grader omitted, PSA, BGS, SGC and CGC were all tried). The card was not created and nothing was listed. Retry with card details plus grader and images to list it from your own details, pending admin review. Check the cert number, and send grader if you know it. Newly graded slabs can take time to appear in the grader's database; you can also add the card with full details in the dashboard. |
| 503 | `cert_lookup_unavailable` | The grader's lookup service is temporarily at capacity or unreachable, so the cert could not be verified. Nothing was created. Retry later, or resend with card details plus grader and images to list from your own details, pending admin review. Honor Retry-After and retry the same request (reuse the Idempotency-Key). |
| 409 | `idempotency_key_reuse` | The Idempotency-Key was already used with a DIFFERENT request payload. Keys pin one request's outcome; reusing one for different work is a client bug. Generate a fresh key per logical request. Reuse a key only to retry the identical request. |
| 409 | `idempotency_request_in_flight` | A request with this Idempotency-Key is still executing. The retry arrived before the first attempt finished. Wait Retry-After seconds and retry with the SAME key to receive the stored outcome. |
| 429 | `rate_limited` | The leaky bucket for this credential is full. The bucket is charged before anything else, so hot retry loops keep themselves throttled. Wait Retry-After seconds. Watch X-SlabDynasty-Api-Call-Limit and slow down as used/capacity approaches 1. |
| 503 | `partner_api_writes_disabled` | Write endpoints are temporarily disabled while Slab Dynasty resolves an incident. Read endpoints are unaffected. Honor Retry-After and retry the write later. Keep reads flowing normally. |
| 503 | `partner_api_disabled` | The Partner API is temporarily unavailable while Slab Dynasty resolves an incident. Nothing is wrong with your key or your request. Honor Retry-After and retry with backoff. Follow status updates from support. |
| 500 | `internal_error` | Something failed on our side. The response never carries internal detail — the request_id is the support handle. Retry with backoff. If it persists, contact support and quote slab_error.request_id. |

### Records we could not render

Separately from the envelopes above, an individual record can come back in
degraded form:

```json
{
  "id": "68d0000000000000000000ff",
  "slab_serialization_error": {
    "code": "serialization_failed",
    "message": "This record could not be rendered and is reported in degraded form."
  }
}
```

This is a 200, and it is deliberate: the rest of the response is complete and
real. Rather than fail a whole page because one stored record is malformed —
which would also block every later page, since the cursor cannot advance past
it — we return the record's id and mark it. Skip any record carrying
`slab_serialization_error`, keep processing the rest, and send us the id.
It is always our defect, never something your request can cause.

## Webhooks

Instead of polling, register an HTTPS endpoint (dashboard: Profile →
Developers → Webhooks) and Slab Dynasty POSTs a signed JSON snapshot when a
subscribed event happens. The payload is the SAME resource the corresponding
GET returns — one deserializer covers both — sent bare (no `{"product": …}`
wrapper), with the topic in a header.

| Topic | Payload | Fires when |
| --- | --- | --- |
| `products/create` | product | A listing went up — created through the API, the dashboard, or an import. Payload is the product resource as GET /products/{id}.json returns it. |
| `products/update` | product | A listing changed: price, title, description, offer settings, reactivation, or a status change you didn't make yourself (expiry, admin review). Also fires when a listing sells — status becomes archived with slab_unavailable_reason: "sold". |
| `products/delete` | product | A listing was taken off the marketplace (soft deactivation — the same thing DELETE /products/{id}.json does). The payload still serializes the full resource so you can see its final state. |
| `orders/create` | order | A buyer bought one of your cards — marketplace checkout, accepted offer, or any other sale path. Payload is the order resource as GET /orders/{id}.json returns it. |
| `orders/updated` | order | An order's lifecycle moved: shipped to the hub, authenticated, shipped to the buyer, delivered, refunded, payout released. Fires on every status transition, so consume it idempotently. |
| `orders/cancelled` | order | An order was cancelled or failed — buyer cancellation, admin cancellation, or payment failure. cancelled_at is set and financial_status reflects the refund state. |

Request headers on every delivery:

| Header | Meaning |
| --- | --- |
| `X-SlabDynasty-Webhook-Signature` | `t=<unix-seconds>,v1=<hex>` — HMAC-SHA256 of `t.rawBody` with the endpoint's signing secret. Verify every request. |
| `X-SlabDynasty-Webhook-Topic` | The topic, e.g. `orders/create`. |
| `X-SlabDynasty-Webhook-Id` | The event id (`evt_…`), stable across retries — deduplicate on it. |
| `X-SlabDynasty-Webhook-Attempt` | 1-based attempt counter, up to 8. |
| `X-SlabDynasty-Api-Version` | API version whose serializers shaped the payload. |
| `X-SlabDynasty-Api-Mode` | `live` or `test`, matching the endpoint's environment. |
| `X-SlabDynasty-Webhook-Test` | `true` only on dashboard test deliveries. |

Rules: answer any 2xx within 10 seconds — ack first, process async. Anything
else (including a timeout) is retried with backoff up to 8
attempts over roughly a day, each retry re-signed with a fresh timestamp. An
endpoint that only fails is auto-disabled (re-enable it in the dashboard). The
signing secret (`slab_whsec_…`) is per endpoint, re-revealable and rotatable
from the dashboard; the dashboard's webhook log shows every delivery, attempt
and response code, and can redeliver any event.

```js
import crypto from "node:crypto";
import express from "express";

const app = express();

function verifySlabDynastyWebhook(rawBody, signatureHeader, secret) {
  // Header shape: "t=<unix-seconds>,v1=<hex>"
  const params = new Map(
    signatureHeader.split(",").map((part) => {
      const i = part.indexOf("=");
      return [part.slice(0, i).trim(), part.slice(i + 1).trim()];
    })
  );
  const t = params.get("t");
  const v1 = params.get("v1");
  if (!t || !v1) return false;

  // Replay guard: reject stamps older than an hour. Retries are re-signed
  // at send time, so a legitimate delivery is never near this boundary.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 60 * 60) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  return (
    v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
  );
}

app.post(
  "/webhooks/slabdynasty",
  // The RAW body, before any JSON parsing — the signature covers the exact
  // bytes we sent, and a re-serialized body will not match.
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = verifySlabDynastyWebhook(
      req.body.toString("utf8"),
      req.get("X-SlabDynasty-Webhook-Signature") ?? "",
      process.env.SLAB_WEBHOOK_SECRET // from the dashboard: slab_whsec_...
    );
    if (!ok) return res.status(401).end();

    // Ack fast (any 2xx within 10s), then process asynchronously.
    res.status(200).end();

    const topic = req.get("X-SlabDynasty-Webhook-Topic");     // e.g. "orders/create"
    const eventId = req.get("X-SlabDynasty-Webhook-Id"); // dedupe on this — retries reuse it
    const resource = JSON.parse(req.body.toString("utf8"));
    // handle(topic, eventId, resource)
  }
);
```

## Endpoints

### GET /2026-07/ping

Verify a credential end to end. Required scope: any authenticated key (no scope required).

Returns the identity behind the presented key without requiring any scope. This is the first call to make when wiring up an integration: a 200 proves the token, the version segment, the rate limiter and your seller's eligibility all line up. A 401 means the token is wrong or revoked; a 403 means the seller cannot currently transact.

Example request:

```bash
curl https://api.slabdynasty.com/2026-07/ping \
  -H "X-SlabDynasty-Access-Token: $SLAB_API_KEY"
```

Example response:

```json
{
  "ping": {
    "ok": true,
    "api_version": "2026-07",
    "mode": "live",
    "seller_id": "68d0000000000000000000aa",
    "key_id": "ABCDEFGH12345678",
    "scopes": ["read_listings"],
    "request_id": "req_01J8ZC4Q7N2M5V8W1X3Y6Z9B0C",
    "server_time": "2026-08-02T12:00:00.000Z"
  }
}
```

### GET /2026-07/listings.json

List your listings. Required scope: `read_listings`.

Your marketplace listings, oldest first. A listing is A CARD PLUS SALE TERMS, and the response is shaped that way: the sale terms (price, status, offers, window) sit on the listing, and the card being sold is nested under card — same field names as the write body's card object, with card.id being the card's own id (the /inventory_items.json record) and card.url its page on the site. An ascending walk appends newly created listings at the tail, so a long-running sync never has rows shuffle underneath its cursor. One variant per listing — even for a lot, where only the whole-lot price is a truthful number. Paging is cursor-based and lives entirely in the Link header: follow rel="next" until it stops appearing. There is no offset paging, because over thousands of listings it drifts as rows sell or expire mid-walk. Shopify-shaped clients can use /products.json instead — a permanent alias serving the identical resource under the {"products": […]} envelope.

**Query parameters**

| Name | Type | | Description |
| --- | --- | --- | --- |
| `limit` | integer | optional | Rows per page, 1–250. Defaults to 50. |
| `fields` | string | optional | Comma-separated top-level keys to return, as in `id,title,status`. Omit for the full resource. |
| `updated_at_min` | string | optional | ISO 8601 date or datetime. Only rows updated at or after this moment are returned — the incremental-sync filter. Walk the full catalog once, then poll with the timestamp of your last successful sync instead of re-walking everything. |
| `page_info` | string | optional | Opaque cursor from the previous response's Link header. Send it verbatim and alone — changing a filter mid-walk is rejected with 400 page_info_filter_mismatch. |

Example request:

```bash
curl "https://api.slabdynasty.com/2026-07/listings.json?limit=10" \
  -H "X-SlabDynasty-Access-Token: $SLAB_API_KEY"
```

Example response:

```json
{
  "listings": [
    {
      "id": "68d0000000000000000000ff",
      "title": "2018 Panini Prizm Luka Doncic #280 PSA 10",
      "status": "active",
      "price": "1250.00",
      "card": {
        "id": "68d0000000000000000000cd",
        "title": "2018 PANINI PRIZM LUKA DONCIC",
        "cert_number": "82736451",
        "grader": "PSA",
        "grade": "10",
        "year": 2018,
        "set": "Panini Prizm",
        "category": "basketball"
      },
      "is_lot": false,
      "card_count": 1,
      "accepts_offers": true,
      "created_at": "2026-07-30T18:04:11.000Z",
      "listed_at": "2026-07-30T18:04:11.000Z"
    }
  ]
}
```

### GET /2026-07/listings/{id}.json

Fetch one listing. Required scope: `read_listings`.

The same listing resource as the collection endpoint, for a single listing. The trailing .json is optional. A listing belonging to another seller returns the same 404 as one that does not exist — distinguishing them would make this route an oracle for which listing ids exist platform-wide. The response's url field is the listing's page on the site — open or share that rather than reconstructing a path from handle. Also served at /products/{id}.json under the {"product": …} envelope.

**Path parameters**

| Name | Type | | Description |
| --- | --- | --- | --- |
| `id` | string | required | The listing id, as returned in a listing's `id` field. |

**Query parameters**

| Name | Type | | Description |
| --- | --- | --- | --- |
| `fields` | string | optional | Comma-separated top-level keys to return. Omit for the full resource. |

Example request:

```bash
curl https://api.slabdynasty.com/2026-07/listings/68d0000000000000000000ff.json \
  -H "X-SlabDynasty-Access-Token: $SLAB_API_KEY"
```

Example response:

```json
{
  "listing": {
    "id": "68d0000000000000000000ff",
    "title": "2018 Panini Prizm Luka Doncic #280 PSA 10",
    "status": "active",
    "price": "1250.00",
    "card": {
      "id": "68d0000000000000000000cd",
      "title": "2018 PANINI PRIZM LUKA DONCIC",
      "cert_number": "82736451",
      "grader": "PSA",
      "grade": "10",
      "url": "https://slabdynasty.com/card/basketball-2018-luka-doncic-psa-10-82736451-68d0000000000000000000cd"
    },
    "unavailable_reason": null,
    "can_reactivate": false,
    "url": "https://slabdynasty.com/marketplace/listing/2018-panini-prizm-luka-doncic-280-psa-10-68d0000000000000000000ff"
  }
}
```

### POST /2026-07/listings.json

List a card for sale. Required scope: `write_listings`.

Creates a marketplace listing for one of your cards, with the same options as the seller's listing form in the app. Name the card ONE of two ways: `inventory_item_id` for a card already in your inventory, or `sku` with a grading cert number — we look the cert up with the grader (send grader: PSA, BGS, SGC or CGC when you know it; omit it and we try all four in that order), create the card in your inventory populated with the grader's own data (title, player, year, set, grade, and for PSA the official slab scans), and list it. If the grader cannot resolve the cert (cert_not_found, or the lookup service is down), send card with your own details plus at least one photo in images — the card is created from them and the listing goes to admin review instead of straight live; grader is required in that case. Cert + price is a complete request; everything else is optional and named after the app's form: title and description, images (up to 5 photo URLs we rehost — the app's listing photos), duration_hours (168, 336, 504 or 720; default 720), accepts_offers (default true) with minimum_offer as the dollar floor, accepts_trades, auto_relist (renew at expiry instead of ending), promoted (the app's Promote Listing: featured placement for an extra 3% seller fee when it sells; one-way, like the app), and scheduled_go_live_at (ISO 8601 — a future instant creates the listing paused until then, with the window counted from go-live). eBay cross-listing and multi-card lots stay dashboard-only. PSA and BGS data is grader-verified and the listing goes live immediately; SGC and CGC data is parsed from a description, so those listings are created pending review. Every dashboard rule — seller standing, card availability, counterfeit blocklist, pending-order reservations, duplicate-listing conflicts, tier caps — applies identically here. The original Shopify-flavored spellings are still accepted as aliases — body_html, the slab_-prefixed field names, the variants[0] wrapper, the {"product": …} envelope, and the /products.json path all keep working. Send an Idempotency-Key so a retried create replays the original outcome. Requires the write_listings scope on a live key, which needs write approval.

**Body fields**

| Name | Type | | Description |
| --- | --- | --- | --- |
| `listing.price` | string | number | required | Asking price in US dollars, 10–1000000, at most 2 decimals. "1250.00" or 1250. |
| `listing.sku` | string | optional | A grading cert number. We look it up with the grader, create the card in your inventory with the grader's data (title, player, year, set, grade, PSA scans) and list it. Send EITHER sku OR inventory_item_id, never both. |
| `listing.inventory_item_id` | string | optional | The id of a card already in your inventory (from GET /inventory_items.json). Alternative to sku. |
| `listing.title` | string (1–180) | optional | OVERRIDE the listing title. Omit it and the title is the grader's card title (e.g. "2018 PANINI PRIZM LUKA DONCIC"). Send it to control exactly what buyers see. |
| `listing.description` | string (≤1000) | optional | Listing description shown to buyers — the Description box in the app's listing form: special features, imperfections, damage, provenance. Plain text; defaults to empty. (body_html is still accepted as an alias.) |
| `listing.images` | array (≤5) | optional | Listing photos, as [{"src": "https://…"}] — the photos the app's listing form uploads. The first is the cover. We download and rehost every photo to our storage before anything is created, so a bad URL fails the whole request cleanly. Only meaningful with a sku create — an existing inventory_item_id keeps the photos it has. Required (≥1) when card details are used, since those photos are what admin review looks at. |
| `listing.images[].src` | string (https URL) | optional | Public https URL of a JPEG, PNG, WEBP or HEIC photo, up to 15MB. |
| `listing.grader` | string | optional | Which grader issued the cert in sku. Send it when you know it; omit it and we try PSA, BGS, SGC, CGC in that order. Ignored with inventory_item_id. One of: PSA, BGS, SGC, CGC. |
| `listing.card` | object | optional | Fallback card details for a sku create, used ONLY when the grader lookup cannot resolve the cert (cert_not_found, or the lookup service is down/at quota). The card is created from these details — plus at least one photo in images — and the listing is created PENDING admin review instead of going live. Requires grader (we won't guess which grader stamped an unverifiable slab). Ignored when the lookup succeeds — grader data wins — and rejected alongside inventory_item_id. |
| `listing.card.title` | string (1–180) | required | The card's title, e.g. "2018 Panini Prizm Luka Doncic #280". The only required field inside card. |
| `listing.card.player` | string (≤120) | optional | Player or character name as printed on the slab label. |
| `listing.card.year` | integer | optional | The card's release year as printed on the slab label. |
| `listing.card.set` | string (≤120) | optional | The set name, e.g. "Panini Prizm". Defaults to "Unknown". |
| `listing.card.variant` | string (≤120) | optional | Parallel or variety, e.g. "Silver Prizm". Defaults to "Base". |
| `listing.card.grade` | string (≤20) | optional | The grade as printed on the slab, e.g. "10" or "9.5". |
| `listing.card.category` | string (≤40) | optional | Card category, e.g. basketball, football, baseball, pokemon. Unrecognized values fall back to non-sport. |
| `listing.duration_hours` | integer | optional | How long the listing stays live: 7, 14, 21 or 30 days, in hours. One of: 168, 336, 504, 720. Default: 720. |
| `listing.accepts_offers` | boolean | optional | Let buyers send offers below the asking price. Default: true. |
| `listing.minimum_offer` | string | number | optional | Dollar floor hint for offers; offers under it are discouraged in the app. Only meaningful when accepts_offers is true. |
| `listing.accepts_trades` | boolean | optional | Accept trade proposals as well as cash offers. Default: false. |
| `listing.auto_relist` | boolean | optional | Renew automatically when the window ends instead of deactivating. Default: false. |
| `listing.promoted` | boolean | optional | The app's Promote Listing: appears in the Featured section for an extra 3% seller fee when the card sells. One-way — once promoted, a listing cannot be un-promoted, exactly like the app. Default: false. |
| `listing.scheduled_go_live_at` | string (ISO 8601) | optional | Schedule the listing: a future instant creates it paused (status draft, reason scheduled) and it goes live then, with the duration counted from go-live. Omit to go live immediately. |

Example request:

```bash
curl -X POST https://api.slabdynasty.com/2026-07/listings.json \
  -H "X-SlabDynasty-Access-Token: $SLAB_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 4d2c1f4e-8a4b-4c1d-9e21-create-luka" \
  -d '{"listing": {"title": "2018 Panini Prizm Luka Doncic #280 PSA 10", "description": "Pack-fresh, centered, ships in a one-touch.", "grader": "PSA", "duration_hours": 720, "accepts_offers": true, "minimum_offer": "1000.00", "accepts_trades": false, "auto_relist": true, "scheduled_go_live_at": "2026-09-01T16:00:00Z", "sku": "82736451", "price": "1250.00"}}'
```

Example response:

```json
{
  "listing": {
    "id": "68d0000000000000000000ff",
    "title": "2018 Panini Prizm Luka Doncic #280 PSA 10",
    "description": "Pack-fresh, centered, ships in a one-touch.",
    "status": "active",
    "price": "1250.00",
    "card": {
      "id": "68d0000000000000000000cd",
      "cert_number": "82736451",
      "grader": "PSA"
    },
    "url": "https://slabdynasty.com/marketplace/listing/2018-panini-prizm-luka-doncic-280-psa-10-68d0000000000000000000ff"
  }
}
```

### PUT /2026-07/listings/{id}.json

Edit a listing. Required scope: `write_listings`.

Updates price, title, description, status, the listing window (duration_hours), a pending schedule (scheduled_go_live_at), or the offer/trade/relist/promotion settings (accepts_offers, minimum_offer, accepts_trades, auto_relist, promoted). Send only the fields you want to change — omitted fields keep their current values. Status uses Shopify's vocabulary: "draft" takes the listing off the marketplace (soft and reversible), "active" relists it — re-running the full reactivation rules with a fresh listing window. A listing that has not gone live yet can be rescheduled with a new scheduled_go_live_at, or released immediately with scheduled_go_live_at: null. Content edits to a listing backed by a user-submitted card re-enter review exactly like a dashboard edit; the listing stays live while reviewed. A sold listing can no longer be edited (409). The original Shopify-flavored spellings are still accepted as aliases — body_html, the slab_-prefixed field names, the variants[0] wrapper, the {"product": …} envelope, and the /products/{id}.json path all keep working.

**Path parameters**

| Name | Type | | Description |
| --- | --- | --- | --- |
| `id` | string | required | The listing id, as returned in a listing's `id` field. |

**Body fields**

| Name | Type | | Description |
| --- | --- | --- | --- |
| `listing.price` | string | number | optional | New asking price in dollars, 10–1000000, at most 2 decimals. |
| `listing.title` | string (1–180) | optional | Replace the listing title. Content edits re-enter review; the listing stays live meanwhile. |
| `listing.description` | string (≤1000) | optional | Replace the description shown to buyers. (body_html is still accepted as an alias.) |
| `listing.status` | string | optional | "draft" takes the listing off the marketplace (soft, reversible). "active" relists it with a fresh window, re-running every reactivation rule. One of: active, draft. |
| `listing.duration_hours` | integer | optional | New window length in hours. With status active it sizes the fresh window; otherwise it extends the current listing. One of: 168, 336, 504, 720. |
| `listing.accepts_offers` | boolean | optional | Turn buyer offers on or off. Turning them off also clears any minimum_offer floor. |
| `listing.minimum_offer` | string | number | null | optional | New dollar floor hint for offers, at most the asking price; null clears it. Ignored while offers are off. Cleared automatically if a price edit drops the price below it. |
| `listing.accepts_trades` | boolean | optional | Accept or stop accepting trade proposals alongside cash offers. |
| `listing.auto_relist` | boolean | optional | Renew automatically when the window ends instead of deactivating. |
| `listing.promoted` | boolean | optional | Opt the listing into the app's Promote Listing placement (extra 3% seller fee when it sells). One-way, exactly like the app: sending false for an already-promoted listing changes nothing. |
| `listing.scheduled_go_live_at` | string (ISO 8601) | null | optional | For a listing that has not gone live yet: a new future instant reschedules it (the window re-counts from the new go-live); null releases it right now. Rejected once the listing is live — and rejected alongside status, since both decide when the listing is live. |

Example request:

```bash
curl -X PUT https://api.slabdynasty.com/2026-07/listings/68d0000000000000000000ff.json \
  -H "X-SlabDynasty-Access-Token: $SLAB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"listing": {"title": "2018 Panini Prizm Luka Doncic #280 PSA 10 — PRICE DROP", "accepts_offers": true, "minimum_offer": "950.00", "price": "1195.00"}}'
```

Example response:

```json
{
  "listing": {
    "id": "68d0000000000000000000ff",
    "title": "2018 Panini Prizm Luka Doncic #280 PSA 10",
    "status": "active",
    "price": "1195.00",
    "minimum_offer": "950.00",
    "accepts_offers": true
  }
}
```

### DELETE /2026-07/listings/{id}.json

Take a listing off the marketplace. Required scope: `write_listings`.

A SOFT deactivation, deliberately: the listing reads deactivated_by_seller with can_reactivate true, and PUT {"listing": {"status": "active"}} puts it back with a fresh window. Nothing is destroyed and your card stays in your inventory. Idempotent — deleting an already-inactive listing succeeds. Works on a pending-approval listing too (it is withdrawn). Responds with the listing as it now stands so you can confirm the change — status archived, unavailable_reason deactivated_by_seller.

**Path parameters**

| Name | Type | | Description |
| --- | --- | --- | --- |
| `id` | string | required | The listing id, as returned in a listing's `id` field. |

Example request:

```bash
curl -X DELETE https://api.slabdynasty.com/2026-07/listings/68d0000000000000000000ff.json \
  -H "X-SlabDynasty-Access-Token: $SLAB_API_KEY"
```

Example response:

```json
{
  "listing": {
    "id": "68d0000000000000000000ff",
    "title": "2018 Panini Prizm Luka Doncic #280 PSA 10",
    "status": "archived",
    "unavailable_reason": "deactivated_by_seller",
    "can_reactivate": true,
    "price": "1250.00"
  }
}
```

### GET /2026-07/orders.json

List your sales as orders. Required scope: `read_orders`.

Every sale, in Shopify's order shape, oldest first. This is where a sold listing's story lives: listings.json keeps the listing itself (status archived, slab_unavailable_reason sold), while the order carries the SD-XXXXXX code, what the buyer paid, and how far the sale has progressed — financial_status moves authorized → paid → (partially_)refunded or voided, fulfillment_status flips to fulfilled when the card ships to the buyer, and slab_seller_shipment is YOUR leg: ship_by, tracking, whether our hub has received it and slab_authentication its verification result. One line item per order; a lot is one line priced as a whole, with every member card in slab_inventory_item_ids. Nothing about the buyer is included — you ship to our hub, never to them. Same Link-header cursor walk and updated_at_min delta sync as every other collection.

**Query parameters**

| Name | Type | | Description |
| --- | --- | --- | --- |
| `limit` | integer | optional | Rows per page, 1–250. Defaults to 50. |
| `fields` | string | optional | Comma-separated top-level keys to return, as in `id,title,status`. Omit for the full resource. |
| `updated_at_min` | string | optional | ISO 8601 date or datetime. Only rows updated at or after this moment are returned — the incremental-sync filter. Walk the full catalog once, then poll with the timestamp of your last successful sync instead of re-walking everything. |
| `page_info` | string | optional | Opaque cursor from the previous response's Link header. Send it verbatim and alone — changing a filter mid-walk is rejected with 400 page_info_filter_mismatch. |
| `status` | string | optional | `open`, `closed`, `cancelled` or `any`. Defaults to `any` — unlike Shopify, whose default of `open` would hide every completed sale from an endpoint that exists to show them. `open` is sold and in progress; `closed` is paid out or delivered; `cancelled` covers seller, buyer and timeout cancellations. |

Example request:

```bash
curl "https://api.slabdynasty.com/2026-07/orders.json?limit=10" \
  -H "X-SlabDynasty-Access-Token: $SLAB_API_KEY"
```

Example response:

```json
{
  "orders": [
    {
      "id": "68d00000000000000000ab01",
      "name": "SD-7K3M9Q",
      "order_number": 158892939,
      "created_at": "2026-08-01T09:15:03.000Z",
      "closed_at": null,
      "cancelled_at": null,
      "financial_status": "authorized",
      "fulfillment_status": null,
      "currency": "USD",
      "subtotal_price": "1250.00",
      "total_tax": "0.00",
      "total_price": "1262.50",
      "total_refunded": "0.00",
      "test": false,
      "line_items": [
        {
          "id": "li_68d00000000000000000ab01",
          "product_id": "68d0000000000000000000ff",
          "title": "2018 Panini Prizm Luka Doncic #280 PSA 10",
          "sku": "82736451",
          "quantity": 1,
          "price": "1250.00",
          "inventory_item_id": "68d0000000000000000000cd",
          "slab_inventory_item_ids": ["68d0000000000000000000cd"],
          "slab_is_lot": false
        }
      ],
      "slab_order_code": "SD-7K3M9Q",
      "slab_sold_at": "2026-08-01T09:15:03.000Z",
      "slab_sale_channel": "marketplace",
      "slab_delivery_method": "ship",
      "slab_seller_shipment": {
        "status": "pending_shipment",
        "tracking_number": null,
        "ship_by": "2026-08-04T09:15:03.000Z",
        "cancel_after": "2026-08-06T09:15:03.000Z",
        "label_url": null
      },
      "slab_authentication": {
        "status": "pending",
        "completed_at": null
      },
      "slab_payout": {
        "status": "pending",
        "amount": null,
        "paid_at": null
      }
    }
  ]
}
```

### GET /2026-07/orders/{id}.json

Fetch one order. Required scope: `read_orders`.

One sale, by order id or by its SD-XXXXXX code — the code is what is printed on the packing slip, so it is accepted directly rather than forcing a list-and-search. An order that belongs to another seller returns the same 404 as one that does not exist.

**Path parameters**

| Name | Type | | Description |
| --- | --- | --- | --- |
| `id` | string | required | The order id as returned in an order's `id` field, or its `SD-XXXXXX` order code (the `name` field). |

**Query parameters**

| Name | Type | | Description |
| --- | --- | --- | --- |
| `fields` | string | optional | Comma-separated top-level keys to return. Omit for the full resource. |

Example request:

```bash
curl https://api.slabdynasty.com/2026-07/orders/SD-7K3M9Q.json \
  -H "X-SlabDynasty-Access-Token: $SLAB_API_KEY"
```

Example response:

```json
{
  "order": {
    "id": "68d00000000000000000ab01",
    "name": "SD-7K3M9Q",
    "order_number": 158892939,
    "created_at": "2026-08-01T09:15:03.000Z",
    "closed_at": "2026-08-09T16:40:00.000Z",
    "cancelled_at": null,
    "financial_status": "paid",
    "fulfillment_status": "fulfilled",
    "currency": "USD",
    "subtotal_price": "1250.00",
    "total_price": "1262.50",
    "total_refunded": "0.00",
    "test": false,
    "line_items": [
      {
        "id": "li_68d00000000000000000ab01",
        "product_id": "68d0000000000000000000ff",
        "title": "2018 Panini Prizm Luka Doncic #280 PSA 10",
        "sku": "82736451",
        "quantity": 1,
        "price": "1250.00",
        "fulfillment_status": "fulfilled"
      }
    ],
    "slab_order_code": "SD-7K3M9Q",
    "slab_seller_shipment": {
      "status": "delivered",
      "tracking_number": "9400111899223197428490",
      "carrier_code": "usps",
      "shipped_at": "2026-08-02T14:02:11.000Z",
      "delivered_to_hub_at": "2026-08-05T18:30:00.000Z"
    },
    "slab_authentication": {
      "status": "passed",
      "completed_at": "2026-08-06T10:12:45.000Z"
    },
    "slab_buyer_delivery": {
      "status": "delivered",
      "shipped_at": "2026-08-06T20:00:00.000Z",
      "delivered_at": "2026-08-09T16:40:00.000Z"
    },
    "slab_payout": {
      "status": "paid",
      "amount": "1137.50",
      "platform_fee": "112.50",
      "paid_at": "2026-08-09T16:40:00.000Z"
    }
  }
}
```

### GET /2026-07/inventory_items.json

List your cards as inventory items. Required scope: `read_inventory`.

The card is the counted unit here, and its grading cert is the sku — so an existing inventory reconciler can point at this endpoint essentially unchanged. Every item is tracked and requires shipping. A card appears whether or not it is currently listed; ask inventory_levels.json whether it is actually sellable.

**Query parameters**

| Name | Type | | Description |
| --- | --- | --- | --- |
| `limit` | integer | optional | Rows per page, 1–250. Defaults to 50. |
| `fields` | string | optional | Comma-separated top-level keys to return, as in `id,title,status`. Omit for the full resource. |
| `updated_at_min` | string | optional | ISO 8601 date or datetime. Only rows updated at or after this moment are returned — the incremental-sync filter. Walk the full catalog once, then poll with the timestamp of your last successful sync instead of re-walking everything. |
| `page_info` | string | optional | Opaque cursor from the previous response's Link header. Send it verbatim and alone — changing a filter mid-walk is rejected with 400 page_info_filter_mismatch. |

Example request:

```bash
curl "https://api.slabdynasty.com/2026-07/inventory_items.json?limit=10" \
  -H "X-SlabDynasty-Access-Token: $SLAB_API_KEY"
```

Example response:

```json
{
  "inventory_items": [
    {
      "id": "68d0000000000000000000cd",
      "sku": "82736451",
      "cost": "900.00",
      "tracked": true,
      "requires_shipping": true,
      "created_at": "2026-06-02T15:22:40.000Z",
      "slab_cert_number": "82736451",
      "slab_grader": "PSA",
      "slab_grade": "10",
      "slab_player": "Luka Doncic",
      "slab_year": "2018",
      "slab_card_set": "Panini Prizm"
    }
  ]
}
```

### GET /2026-07/inventory_levels.json

Per-card availability, with the reason. Required scope: `read_inventory`.

The endpoint a channel reconciler polls. `available` is 0 or 1, and because a zero is lossy — sold, expired, seller-deactivated, flagged, shipped and pulled-into-a-pack all look identical — it always travels with slab_unavailable_reason, slab_reason_detail and slab_can_reactivate. Without those you cannot tell "gone forever" from "relist it". Lot members are resolved even when the sibling card falls on another page.

**Query parameters**

| Name | Type | | Description |
| --- | --- | --- | --- |
| `limit` | integer | optional | Rows per page, 1–250. Defaults to 50. |
| `fields` | string | optional | Comma-separated top-level keys to return, as in `id,title,status`. Omit for the full resource. |
| `updated_at_min` | string | optional | ISO 8601 date or datetime. Only rows updated at or after this moment are returned — the incremental-sync filter. Walk the full catalog once, then poll with the timestamp of your last successful sync instead of re-walking everything. |
| `page_info` | string | optional | Opaque cursor from the previous response's Link header. Send it verbatim and alone — changing a filter mid-walk is rejected with 400 page_info_filter_mismatch. |

Example request:

```bash
curl "https://api.slabdynasty.com/2026-07/inventory_levels.json?limit=10" \
  -H "X-SlabDynasty-Access-Token: $SLAB_API_KEY"
```

Example response:

```json
{
  "inventory_levels": [
    {
      "inventory_item_id": "68d0000000000000000000cd",
      "location_id": "loc_vault",
      "available": 0,
      "updated_at": "2026-08-01T09:15:03.000Z",
      "slab_unavailable_reason": "sold",
      "slab_reason_detail": "Sold on 2026-08-01.",
      "slab_can_reactivate": false
    }
  ]
}
```

### GET /2026-07/locations.json

The two places a card can be. Required scope: `read_inventory`.

Reads no database: locations are a pure function of who is asking. A card is either in your hands or at our hub, so unlike Shopify — where locations are merchant-created — neither is writable. connect, set, adjust and DELETE all return 422, and every location says so in slab_writable rather than leaving you to discover it by trying. No addresses are published for either.

Example request:

```bash
curl https://api.slabdynasty.com/2026-07/locations.json \
  -H "X-SlabDynasty-Access-Token: $SLAB_API_KEY"
```

Example response:

```json
{
  "locations": [
    {
      "id": "loc_seller_68d0000000000000000000aa",
      "name": "Seller Inventory",
      "active": true,
      "slab_location_type": "seller",
      "slab_seller_controlled": true,
      "slab_writable": false
    },
    {
      "id": "loc_vault",
      "name": "Slab Dynasty Vault",
      "country_code": "US",
      "active": true,
      "slab_location_type": "vault",
      "slab_seller_controlled": false,
      "slab_writable": false
    }
  ]
}
```

## Changelog

### 2026-07 — released 2026-07-01

First public release — authentication, the read path and cursor paging.

- **added** — Key-based authentication via X-SlabDynasty-Access-Token or Authorization: Bearer, with per-environment live and test keys.
- **added** — GET /ping — verifies a credential end to end without requiring any scope.
- **added** — GET /products.json and GET /products/{id}.json — marketplace listings in Shopify's product shape, one variant per listing.
- **added** — GET /inventory_items.json — cards as inventory items, with the grading cert as the sku.
- **added** — GET /inventory_levels.json — per-card availability, always accompanied by slab_unavailable_reason and slab_can_reactivate so a zero is never ambiguous.
- **added** — GET /locations.json — the seller's own inventory and the Slab Dynasty vault, both read-only.
- **added** — GET /orders.json and GET /orders/{id}.json — every sale as a Shopify order: order code, what the buyer paid, financial and fulfillment status, your ship-to-hub leg, verification and payout. ?status= takes open, closed, cancelled or any (the default).
- **added** — Cursor pagination via the Link header, with the filter set hashed into the cursor so changing a filter mid-walk is rejected rather than silently skipping rows.
- **added** — Leaky-bucket rate limiting reported in X-SlabDynasty-Api-Call-Limit, in the same used/capacity shape Shopify uses.
- **added** — Error envelopes matching Shopify's two shapes, plus an additive slab_error object carrying a stable machine-readable code and the request id.
