Natural OrderNatural Order API
Back to app →

Natural Order API v1

Build integrations for proximity-based MTG card trading.

18
Endpoints
Bearer
Authentication
JSON
Response Format

Authentication

Generate an API key from your profile page. Include it in every request as a Bearer token.

Authorization: Bearer sk_live_abc123...

Base URL:

https://natural-order.vercel.app/api/v1

Rate Limits

MethodLimit
GET100 requests / minute
POST PATCH DELETE30 requests / minute
Compute matches5 requests / minute

Response Format

All responses return JSON.

Success (single)

{
  "data": { ... }
}

Success (list)

{
  "data": [ ... ],
  "meta": {
    "page": 1,
    "limit": 50,
    "total_count": 128,
    "has_more": true
  }
}

Error

{
  "error": "Descriptive error message"
}

Profile

GET/v1/me

Returns the authenticated user's profile information.

Response

{
  "data": {
    "id": "uuid",
    "email": "user@example.com",
    "display_name": "TraderJoe",
    "preferred_language": "es"
  }
}

Collection

GET/v1/collection

List your collection. Paginated.

ParameterTypeDescription
pageintegerPage number (default: 1)
limitintegerItems per page (default: 50, max: 100)

Response

{
  "data": [
    {
      "id": "uuid",
      "scryfall_id": "abc123",
      "card_name": "Lightning Bolt",
      "quantity": 4,
      "condition": "NM",
      "foil": false,
      "asking_price": 1.20
    }
  ],
  "meta": { "page": 1, "limit": 50, "total_count": 215, "has_more": true }
}
POST/v1/collection

Add a card to your collection.

ParameterTypeDescription
scryfall_id*stringScryfall card ID
quantityintegerDefault: 1
conditionstringNM, LP, MP, HP, DMG (default: NM)
foilbooleanDefault: false
price_modestring"percentage" or "fixed"
price_percentagenumberPercentage of market price (1-200)

Response

{
  "data": {
    "id": "uuid",
    "scryfall_id": "abc123",
    "card_name": "Lightning Bolt",
    "quantity": 4,
    "condition": "NM"
  }
}
PATCH/v1/collection/:id

Update a card in your collection.

ParameterTypeDescription
quantityintegerNew quantity
conditionstringNM, LP, MP, HP, DMG
foilbooleanFoil status
price_modestring"percentage" or "fixed"
price_percentagenumberPercentage of market price
DELETE/v1/collection

Remove a card from your collection.

ParameterTypeDescription
id*stringCollection entry ID to remove

Response

{
  "data": { "deleted": true }
}

Wishlist

GET/v1/wishlist

List your wishlist. Paginated.

ParameterTypeDescription
pageintegerPage number (default: 1)
limitintegerItems per page (default: 50, max: 100)

Response

{
  "data": [
    {
      "id": "uuid",
      "scryfall_id": "abc123",
      "card_name": "Ragavan, Nimble Pilferer",
      "quantity": 1,
      "max_price": 50.00,
      "min_condition": "LP",
      "priority": 8
    }
  ],
  "meta": { "page": 1, "limit": 50, "total_count": 42, "has_more": false }
}
POST/v1/wishlist

Add a card to your wishlist.

ParameterTypeDescription
scryfall_id*stringScryfall card ID
quantityintegerDefault: 1
max_pricenumberMaximum price in USD
min_conditionstringNM, LP, MP, HP, DMG (default: LP)
foil_preferencestring"any", "foil_only", or "non_foil"
priorityinteger1-10 (default: 5)
PATCH/v1/wishlist/:id

Update a wishlist item.

ParameterTypeDescription
quantityintegerNew quantity
max_pricenumberMaximum price in USD
min_conditionstringMinimum acceptable condition
foil_preferencestring"any", "foil_only", or "non_foil"
priorityinteger1-10
DELETE/v1/wishlist

Remove a card from your wishlist.

ParameterTypeDescription
id*stringWishlist entry ID to remove

Card Search

GET/v1/cards/search?q=...

Search for Magic cards by name. Powered by Scryfall data.

ParameterTypeDescription
q*stringSearch query (min 2 characters)

Response

{
  "data": [
    {
      "scryfall_id": "abc123",
      "name": "Lightning Bolt",
      "set_code": "lea",
      "set_name": "Limited Edition Alpha",
      "image_uri": "https://cards.scryfall.io/..."
    }
  ]
}

Preferences

GET/v1/preferences

Get your trading preferences.

Response

{
  "data": {
    "trade_mode": "both",
    "default_price_percentage": 80,
    "minimum_price": 0.50,
    "collection_paused": false,
    "price_source": "cardkingdom"
  }
}
PATCH/v1/preferences

Update your trading preferences.

ParameterTypeDescription
trade_modestring"trade", "sell", "buy", or "both"
default_price_percentagenumberGlobal discount (1-200)
minimum_pricenumberMinimum price in USD
collection_pausedbooleanPause collection visibility
price_sourcestringIgnored — always cardkingdom. Kept for backwards compatibility.

Matches

GET/v1/matches

List your trading matches. Paginated.

ParameterTypeDescription
pageintegerPage number (default: 1)
limitintegerItems per page (default: 20, max: 50)
statusstringFilter by status (active, requested, confirmed, etc.)

Response

{
  "data": [
    {
      "id": "uuid",
      "counterpart": { "display_name": "CardShark", "avatar_url": "..." },
      "match_type": "two_way",
      "distance_km": 3.5,
      "cards_you_want": 5,
      "cards_they_want": 3,
      "status": "active"
    }
  ],
  "meta": { "page": 1, "limit": 20, "total_count": 8, "has_more": false }
}
GET/v1/matches/:id

Get full match details including matched cards.

Response

{
  "data": {
    "id": "uuid",
    "counterpart": { "display_name": "CardShark" },
    "match_type": "two_way",
    "status": "active",
    "distance_km": 3.5,
    "cards_you_want": [
      { "card_id": "uuid", "card_name": "Lightning Bolt", "asking_price": 1.20 }
    ],
    "cards_they_want": [
      { "card_id": "uuid", "card_name": "Counterspell", "asking_price": 0.80 }
    ]
  }
}
PATCH/v1/matches/:id

Update match status or favorite flag.

ParameterTypeDescription
statusstring"contacted" or "dismissed"
is_favoritebooleanToggle favorite
POST/v1/matches/compute

Run the matching algorithm. Finds nearby users with compatible collections and wishlists. Rate limited to 5 requests per minute.

Response

{
  "data": {
    "matches_found": 5,
    "new_matches": 3,
    "updated_matches": 2
  }
}

Trade Actions

Trade lifecycle: active requested confirmed completed

POST/v1/matches/:id/request

Request a trade with this match. The other user will be notified.

Response

{
  "data": { "status": "requested", "requested_by": "your-uuid" }
}
DELETE/v1/matches/:id/request

Cancel or reject a pending trade request.

POST/v1/matches/:id/confirm

Confirm the trade. Starts a 15-day escrow period. Cards are reserved from other matches.

Response

{
  "data": {
    "status": "confirmed",
    "confirmed_at": "2026-01-15T10:30:00Z",
    "escrow_expires_at": "2026-01-30T10:30:00Z"
  }
}
POST/v1/matches/:id/complete

Mark the trade as completed (or not).

ParameterTypeDescription
completed*booleantrue to complete, false to cancel

Match Cards

PATCH/v1/matches/:id/cards

Toggle exclusion of a specific card in a match.

ParameterTypeDescription
card_id*stringMatch card ID
is_excluded*booleantrue to exclude, false to include
PUT/v1/matches/:id/cards

Bulk update card exclusions. All cards not in the array will be included.

ParameterTypeDescription
excluded_card_ids*string[]Array of match card IDs to exclude

Response

{
  "data": { "updated": 3 }
}

Comments

GET/v1/matches/:id/comments

List comments on a match. Paginated.

ParameterTypeDescription
pageintegerPage number (default: 1)
limitintegerItems per page (default: 50)

Response

{
  "data": [
    {
      "id": "uuid",
      "content": "Hey, I can meet downtown tomorrow!",
      "author": { "display_name": "TraderJoe" },
      "created_at": "2026-01-15T14:30:00Z"
    }
  ],
  "meta": { "page": 1, "limit": 50, "total_count": 3, "has_more": false }
}
POST/v1/matches/:id/comments

Post a comment on a match. Max 300 characters. Limited to 10 comments per month.

ParameterTypeDescription
content*stringComment text (max 300 chars)

Stores API

For Magic stores to sync their inventory into Natural Order. Once connected, your store shows up in user matches — buyers nearby see your stock when they search for cards you carry. Stores must be approved before receiving an API key.

Authentication

Store API keys are prefixed with nos_live_ and scoped to a single store. Send as a Bearer token. The store ID is inferred from the key — never include it in the request body.

Authorization: Bearer nos_live_xxxxxxxxxxxxxxxxxxxxxxxx

Card identification

Each listing must include exactly one identifier. Resolved in priority order:

  1. scryfall_id — recommended, exact printing match
  2. set_code + collector_number
  3. name + set_code — last resort, ambiguous matches are rejected

Cards we don't know yet are imported automatically from Scryfall on first sync. Cards Scryfall doesn't recognize are rejected with scryfall_id_not_found.

Listing schema

A single inventory entry:

{
  // Identifier (one of):
  "scryfall_id": "e2d1f9c2-...",          // preferred
  "set_code": "ltr",                      // alt: with collector_number
  "collector_number": "0123",             //   or: with name
  "name": "Lightning Bolt",               // last resort, requires set_code

  // Required attributes:
  "condition": "NM",                      // NM | LP | MP | HP | DMG
  "finish": "nonfoil",                    // nonfoil | foil | etched
  "language": "en",                       // en, es, pt, fr, de, it, ja, ko, ru, zhs, zht
  "quantity": 4,                          // integer >= 1
  "price": {
    "amount": 1.50,                       // decimal in your local currency
    "currency": "USD"                     // ISO 4217: USD, ARS, CLP, MXN, PEN, BRL, ...
  },

  // Recommended (powers cart deep-links and product pages):
  "product_url": "https://store.com/p/lightning-bolt",
  "store_product_id": "SKU-12345",
  "store_listing_id": "VARIANT-67",
  "add_to_cart_url": "https://store.com/cart/add?variant_id=67&qty=1"
}
ParameterTypeDescription
scryfall_idstringScryfall printing UUID. Recommended — exact match, no fallback needed.
set_codestringScryfall set code (e.g., "ltr"). Required if scryfall_id absent.
collector_numberstringCollector number within the set. Combined with set_code.
namestringCard name. Last-resort identifier; combine with set_code.
condition*stringNM, LP, MP, HP, DMG
finish*stringnonfoil | foil | etched. is_foil:boolean is also accepted (no etched).
language*stringScryfall language code. No default — required even for English.
quantity*integerStock for this listing. Must be >= 1.
price.amount*numberPrice in your local currency. Decimal with up to 2 places.
price.currency*stringISO 4217 code. Converted to USD daily at ingest.
product_urlstringPublic URL to the product page. Used for the "view in store" CTA.
store_product_idstringYour internal product ID (SKU). Useful for support.
store_listing_idstringYour internal variant/listing ID (specific condition + language).
add_to_cart_urlstringURL that adds 1 unit of this listing to the cart. Required to enable the shopper-to-cart redirect flow.

Sync semantics

Each successful sync performs a transactional diff against your previous inventory:

  • Listings present in the new sync are upserted by (store_id, scryfall_id, condition, finish, language). Stock and price update in place; row IDs are preserved.
  • Listings absent from the new sync are deleted.
  • The whole operation is atomic — partial failures revert.

Use the simple endpoint for catalogs ≤ 5,000 listings. Use a chunked session for anything larger.

POST/v1/stores/inventory

Replace your full inventory in a single request. Body limit: 4 MB (~5,000 listings).

Body

{
  "listings": [
    {
      "scryfall_id": "e2d1...",
      "condition": "NM",
      "finish": "nonfoil",
      "language": "en",
      "quantity": 4,
      "price": { "amount": 1.50, "currency": "USD" },
      "product_url": "https://store.com/p/lightning-bolt"
    }
  ]
}

Response

{
  "data": {
    "accepted": 4823,
    "rejected": [
      { "index": 12, "identifier": "ltr-205", "reason": "scryfall_id_not_found" },
      { "index": 88, "identifier": "Black Lotus|leb", "reason": "ambiguous_name" }
    ],
    "total": 4825,
    "swapped_at": "2026-05-10T14:30:00Z"
  }
}
POST/v1/stores/inventory/sessions

Open a chunked sync session. Returns a session_id valid for 60 minutes. Only one session per store may be open at a time.

Response

{
  "data": {
    "session_id": "ses_8f3a91...",
    "expires_at": "2026-05-10T15:30:00Z",
    "max_chunks": 100,
    "max_listings_per_chunk": 1000
  }
}
POST/v1/stores/inventory/sessions/:session_id/chunks

Stage a chunk of listings. Idempotent by chunk_index — retrying with the same index replaces the previous payload for that index.

Body

{
  "chunk_index": 0,
  "listings": [ /* up to 1000 listings */ ]
}

Response

{
  "data": {
    "session_id": "ses_8f3a91...",
    "chunk_index": 0,
    "accepted": 998,
    "rejected": [
      { "index": 412, "identifier": "ltr-X9", "reason": "set_code_invalid" }
    ],
    "chunks_received": 1
  }
}
POST/v1/stores/inventory/sessions/:session_id/finalize

Commit all staged chunks atomically. Performs the diff sync described above. Pass expected_chunk_count as a sanity check — if it doesn't match what the server received, the call fails without committing.

Body

{ "expected_chunk_count": 12 }

Response

{
  "data": {
    "session_id": "ses_8f3a91...",
    "state": "finalized",
    "total_accepted": 11843,
    "total_rejected": 27,
    "swapped_at": "2026-05-10T14:42:18Z"
  }
}
DELETE/v1/stores/inventory/sessions/:session_id

Discard all staged chunks. Your previous inventory is untouched. No-op if the session is already finalized or expired.

GET/v1/stores/inventory/sessions/:session_id

Inspect a session in progress. The rejected_sample array is capped at 50 entries to keep responses small.

Response

{
  "data": {
    "session_id": "ses_8f3a91...",
    "state": "open",
    "chunks_received": 7,
    "listings_staged": 6982,
    "listings_rejected": 14,
    "rejected_sample": [
      { "chunk_index": 2, "index": 412, "identifier": "ltr-X9", "reason": "set_code_invalid" }
    ],
    "started_at": "2026-05-10T14:30:00Z",
    "expires_at": "2026-05-10T15:30:00Z",
    "finalized_at": null
  }
}
GET/v1/stores/inventory/sync-status

Snapshot of your store's most recent successful sync. Useful for rendering “last updated” in your own dashboard.

Response

{
  "data": {
    "last_synced_at": "2026-05-10T14:42:18Z",
    "inventory_count": 11843,
    "currency": "ARS",
    "fx_rate_to_usd": 0.00081,
    "fx_rate_updated_at": "2026-05-10T12:00:00Z"
  }
}

Rejection reasons

Per-listing errors are returned in the rejected array and never abort the rest of the sync. The reason enum is closed and stable:

ReasonDescription
missing_identifierNo scryfall_id, set_code, or name+set_code provided
scryfall_id_not_foundScryfall does not know this printing
set_code_invalidSet code does not exist on Scryfall
collector_number_not_foundSet + collector number combination has no printing
ambiguous_nameName + set matched multiple printings; use scryfall_id
invalid_conditionCondition not in NM | LP | MP | HP | DMG
invalid_finishFinish not in nonfoil | foil | etched
invalid_languageLanguage not in the supported Scryfall enum
invalid_quantityQuantity is not a positive integer
invalid_currencyCurrency is not a supported ISO 4217 code
price_negativePrice amount is negative or zero
duplicate_listingSame (scryfall_id, condition, finish, language) appears twice in the same sync

Limits

ConstraintValue
Body size (any single request)4 MB
Listings per chunk1,000
Chunks per session100 (~100,000 listings total)
Open sessions per store1 at a time
Session TTL60 minutes
POST /v1/stores/inventory (simple sync)2 requests / 30 min
Chunk uploads60 requests / minute
GET status & sync-status60 requests / minute

Stores: Cart Handoff

When a Natural Order user matches with your store and clicks “Buy whole cart at [Store]”, we redirect them to a URL on your domain that you tell us about. You then fetch the canonical card list from us and add it to your cart. We don't process payments, hold stock, or record the purchase — this is purely a deep-link with a verified payload.

Configure your cart handler URL

Set the URL on your site that accepts the handoff from the My Store dashboard. Updates take effect immediately; if you need help, reach out at info@naturalorder.app.

Example cart_handler_url:
  https://yourstore.com/nos/cart

The redirect we send

When the user clicks the CTA, we open the URL below in a new tab. Your handler reads nos_match and nos_token from the query string.

GET https://yourstore.com/nos/cart
  ?nos_match=<store_match_id>
  &nos_token=<short_lived_token>

The token is single-use, expires after 10 minutes, and is scoped to your store + this specific match. Treat it as a capability you forward straight to our fetch endpoint — don't persist it.

Fetch the cart contents

On your handler (server-side), call our cart endpoint with your API key as Bearer plus the token. We return the cards the user wants to buy from your store, with the IDs you originally pushed during inventory sync.

GET /api/v1/stores/carts/<store_match_id>?token=<nos_token>
Authorization: Bearer nos_live_xxxxxxxxxxxxxxxxxxxxxxxx

Successful response:

{
  "data": {
    "match_id": "1f4a...",
    "generated_at": "2026-05-10T19:42:11.123Z",
    "items": [
      {
        "scryfall_id": "e2d1f9c2-...",
        "oracle_id": "abc...",
        "card_name": "Lightning Bolt",
        "set_code": "ltr",
        "condition": "NM",
        "is_foil": false,
        "finish": "nonfoil",
        "language": "en",
        "quantity": 2,
        "store_product_id": "SKU-12345",
        "store_listing_id": "LST-98765",
        "add_to_cart_url": "https://yourstore.com/cart/add?id=..."
      }
    ]
  }
}

Behavior + invariants

  • Cards the user marked as excluded in the match are not in the payload.
  • quantity reflects what the user wants, copied from the match — not necessarily what you currently have in stock.
  • We don't do a stock check at fetch time. If you sold out since the user opened the match, decide how to handle it on your side (partial cart, replace with closest, etc.).
  • We don't include our reference pricing — apply your own checkout price.
  • The token is consumed on the first successful fetch. A second call returns 410 consumed.
  • If the token is for a different store or match, we return 404 — don't expose the discrepancy to the buyer, just bail.

Error responses

StatusCodeMeaning
400Missing match_id or token query param
401Invalid or missing API key
404Token not found / wrong store
410consumedToken already used
410expiredToken past expires_at
429Rate limit exceeded

Minimal Node.js handler example

// pages/nos/cart.ts (or your framework's equivalent)
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
  const matchId = req.nextUrl.searchParams.get("nos_match");
  const token = req.nextUrl.searchParams.get("nos_token");
  if (!matchId || !token) return new Response("Bad request", { status: 400 });

  const res = await fetch(
    `https://naturalorder.app/api/v1/stores/carts/${matchId}?token=${token}`,
    {
      headers: { Authorization: `Bearer ${process.env.NOS_API_KEY}` },
    },
  );
  if (!res.ok) return new Response("Cart fetch failed", { status: 502 });
  const { data } = await res.json();

  // 1. Add each item to your store's cart using your own IDs:
  //    data.items.forEach(item => addToCart(item.store_listing_id, item.quantity));
  // 2. Redirect the user to your cart page.
  return NextResponse.redirect(new URL("/cart", req.url));
}

Rate limits

120 requests / minute per store API key. If you serve high traffic from Natural Order, ping us — we'll raise the bucket.


Error Codes

StatusCodeDescription
400Bad RequestInvalid parameters or missing required fields
401UnauthorizedMissing or invalid API key
404Not FoundResource does not exist or is not accessible
409ConflictAction conflicts with current state (e.g., cards in escrow)
429Rate LimitToo many requests. Check Retry-After header
500Server ErrorInternal error. Contact support if persistent

Error response shape

{
  "error": "Missing required field: scryfall_id"
}

Rate Limiting

Rate limit information is included in response headers.

HeaderDescription
X-RateLimit-LimitMaximum requests allowed in the window
X-RateLimit-RemainingRequests remaining in the current window
X-RateLimit-ResetUnix timestamp when the window resets
Retry-AfterSeconds to wait before retrying (only on 429)

Example 429 response

HTTP/1.1 429 Too Many Requests
Retry-After: 23
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1706112000

{
  "error": "Rate limit exceeded. Try again in 23 seconds."
}

Full Workflow Example

End-to-end example of finding and completing a trade via the API.

  1. 1

    Search for a card

    GET /v1/cards/search?q=Lightning%20Bolt
  2. 2

    Add to your wishlist

    POST /v1/wishlist
    {
      "scryfall_id": "e2d1f9c2-...",
      "max_price": 2.00,
      "priority": 8
    }
  3. 3

    Add cards to your collection

    POST /v1/collection
    {
      "scryfall_id": "f5a2b8e1-...",
      "quantity": 2,
      "condition": "NM"
    }
  4. 4

    Compute matches

    POST /v1/matches/compute
  5. 5

    Review matches

    GET /v1/matches?status=active
  6. 6

    Request a trade

    POST /v1/matches/abc123/request
  7. 7

    Other user confirms the trade

    POST /v1/matches/abc123/confirm
  8. 8

    Mark trade as complete

    POST /v1/matches/abc123/complete
    {
      "completed": true
    }