Natural Order API v1
Build integrations for proximity-based MTG card trading.
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
| Method | Limit |
|---|---|
| GET | 100 requests / minute |
| POST PATCH DELETE | 30 requests / minute |
| Compute matches | 5 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
/v1/meReturns the authenticated user's profile information.
Response
{
"data": {
"id": "uuid",
"email": "user@example.com",
"display_name": "TraderJoe",
"preferred_language": "es"
}
}Collection
/v1/collectionList your collection. Paginated.
| Parameter | Type | Description |
|---|---|---|
| page | integer | Page number (default: 1) |
| limit | integer | Items 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 }
}/v1/collectionAdd a card to your collection.
| Parameter | Type | Description |
|---|---|---|
| scryfall_id* | string | Scryfall card ID |
| quantity | integer | Default: 1 |
| condition | string | NM, LP, MP, HP, DMG (default: NM) |
| foil | boolean | Default: false |
| price_mode | string | "percentage" or "fixed" |
| price_percentage | number | Percentage of market price (1-200) |
Response
{
"data": {
"id": "uuid",
"scryfall_id": "abc123",
"card_name": "Lightning Bolt",
"quantity": 4,
"condition": "NM"
}
}/v1/collection/:idUpdate a card in your collection.
| Parameter | Type | Description |
|---|---|---|
| quantity | integer | New quantity |
| condition | string | NM, LP, MP, HP, DMG |
| foil | boolean | Foil status |
| price_mode | string | "percentage" or "fixed" |
| price_percentage | number | Percentage of market price |
/v1/collectionRemove a card from your collection.
| Parameter | Type | Description |
|---|---|---|
| id* | string | Collection entry ID to remove |
Response
{
"data": { "deleted": true }
}Wishlist
/v1/wishlistList your wishlist. Paginated.
| Parameter | Type | Description |
|---|---|---|
| page | integer | Page number (default: 1) |
| limit | integer | Items 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 }
}/v1/wishlistAdd a card to your wishlist.
| Parameter | Type | Description |
|---|---|---|
| scryfall_id* | string | Scryfall card ID |
| quantity | integer | Default: 1 |
| max_price | number | Maximum price in USD |
| min_condition | string | NM, LP, MP, HP, DMG (default: LP) |
| foil_preference | string | "any", "foil_only", or "non_foil" |
| priority | integer | 1-10 (default: 5) |
/v1/wishlist/:idUpdate a wishlist item.
| Parameter | Type | Description |
|---|---|---|
| quantity | integer | New quantity |
| max_price | number | Maximum price in USD |
| min_condition | string | Minimum acceptable condition |
| foil_preference | string | "any", "foil_only", or "non_foil" |
| priority | integer | 1-10 |
/v1/wishlistRemove a card from your wishlist.
| Parameter | Type | Description |
|---|---|---|
| id* | string | Wishlist entry ID to remove |
Card Search
/v1/cards/search?q=...Search for Magic cards by name. Powered by Scryfall data.
| Parameter | Type | Description |
|---|---|---|
| q* | string | Search 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
/v1/preferencesGet your trading preferences.
Response
{
"data": {
"trade_mode": "both",
"default_price_percentage": 80,
"minimum_price": 0.50,
"collection_paused": false,
"price_source": "cardkingdom"
}
}/v1/preferencesUpdate your trading preferences.
| Parameter | Type | Description |
|---|---|---|
| trade_mode | string | "trade", "sell", "buy", or "both" |
| default_price_percentage | number | Global discount (1-200) |
| minimum_price | number | Minimum price in USD |
| collection_paused | boolean | Pause collection visibility |
| price_source | string | Ignored — always cardkingdom. Kept for backwards compatibility. |
Matches
/v1/matchesList your trading matches. Paginated.
| Parameter | Type | Description |
|---|---|---|
| page | integer | Page number (default: 1) |
| limit | integer | Items per page (default: 20, max: 50) |
| status | string | Filter 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 }
}/v1/matches/:idGet 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 }
]
}
}/v1/matches/:idUpdate match status or favorite flag.
| Parameter | Type | Description |
|---|---|---|
| status | string | "contacted" or "dismissed" |
| is_favorite | boolean | Toggle favorite |
/v1/matches/computeRun 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
/v1/matches/:id/requestRequest a trade with this match. The other user will be notified.
Response
{
"data": { "status": "requested", "requested_by": "your-uuid" }
}/v1/matches/:id/requestCancel or reject a pending trade request.
/v1/matches/:id/confirmConfirm 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"
}
}/v1/matches/:id/completeMark the trade as completed (or not).
| Parameter | Type | Description |
|---|---|---|
| completed* | boolean | true to complete, false to cancel |
Match Cards
/v1/matches/:id/cardsToggle exclusion of a specific card in a match.
| Parameter | Type | Description |
|---|---|---|
| card_id* | string | Match card ID |
| is_excluded* | boolean | true to exclude, false to include |
/v1/matches/:id/cardsBulk update card exclusions. All cards not in the array will be included.
| Parameter | Type | Description |
|---|---|---|
| excluded_card_ids* | string[] | Array of match card IDs to exclude |
Response
{
"data": { "updated": 3 }
}Comments
/v1/matches/:id/commentsList comments on a match. Paginated.
| Parameter | Type | Description |
|---|---|---|
| page | integer | Page number (default: 1) |
| limit | integer | Items 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 }
}/v1/matches/:id/commentsPost a comment on a match. Max 300 characters. Limited to 10 comments per month.
| Parameter | Type | Description |
|---|---|---|
| content* | string | Comment 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:
scryfall_id— recommended, exact printing matchset_code+collector_numbername+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"
}| Parameter | Type | Description |
|---|---|---|
| scryfall_id | string | Scryfall printing UUID. Recommended — exact match, no fallback needed. |
| set_code | string | Scryfall set code (e.g., "ltr"). Required if scryfall_id absent. |
| collector_number | string | Collector number within the set. Combined with set_code. |
| name | string | Card name. Last-resort identifier; combine with set_code. |
| condition* | string | NM, LP, MP, HP, DMG |
| finish* | string | nonfoil | foil | etched. is_foil:boolean is also accepted (no etched). |
| language* | string | Scryfall language code. No default — required even for English. |
| quantity* | integer | Stock for this listing. Must be >= 1. |
| price.amount* | number | Price in your local currency. Decimal with up to 2 places. |
| price.currency* | string | ISO 4217 code. Converted to USD daily at ingest. |
| product_url | string | Public URL to the product page. Used for the "view in store" CTA. |
| store_product_id | string | Your internal product ID (SKU). Useful for support. |
| store_listing_id | string | Your internal variant/listing ID (specific condition + language). |
| add_to_cart_url | string | URL 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.
/v1/stores/inventoryReplace 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"
}
}/v1/stores/inventory/sessionsOpen 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
}
}/v1/stores/inventory/sessions/:session_id/chunksStage 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
}
}/v1/stores/inventory/sessions/:session_id/finalizeCommit 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"
}
}/v1/stores/inventory/sessions/:session_idDiscard all staged chunks. Your previous inventory is untouched. No-op if the session is already finalized or expired.
/v1/stores/inventory/sessions/:session_idInspect 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
}
}/v1/stores/inventory/sync-statusSnapshot 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:
| Reason | Description |
|---|---|
| missing_identifier | No scryfall_id, set_code, or name+set_code provided |
| scryfall_id_not_found | Scryfall does not know this printing |
| set_code_invalid | Set code does not exist on Scryfall |
| collector_number_not_found | Set + collector number combination has no printing |
| ambiguous_name | Name + set matched multiple printings; use scryfall_id |
| invalid_condition | Condition not in NM | LP | MP | HP | DMG |
| invalid_finish | Finish not in nonfoil | foil | etched |
| invalid_language | Language not in the supported Scryfall enum |
| invalid_quantity | Quantity is not a positive integer |
| invalid_currency | Currency is not a supported ISO 4217 code |
| price_negative | Price amount is negative or zero |
| duplicate_listing | Same (scryfall_id, condition, finish, language) appears twice in the same sync |
Limits
| Constraint | Value |
|---|---|
| Body size (any single request) | 4 MB |
| Listings per chunk | 1,000 |
| Chunks per session | 100 (~100,000 listings total) |
| Open sessions per store | 1 at a time |
| Session TTL | 60 minutes |
| POST /v1/stores/inventory (simple sync) | 2 requests / 30 min |
| Chunk uploads | 60 requests / minute |
| GET status & sync-status | 60 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.
quantityreflects 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
| Status | Code | Meaning |
|---|---|---|
| 400 | — | Missing match_id or token query param |
| 401 | — | Invalid or missing API key |
| 404 | — | Token not found / wrong store |
| 410 | consumed | Token already used |
| 410 | expired | Token past expires_at |
| 429 | — | Rate 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
| Status | Code | Description |
|---|---|---|
| 400 | Bad Request | Invalid parameters or missing required fields |
| 401 | Unauthorized | Missing or invalid API key |
| 404 | Not Found | Resource does not exist or is not accessible |
| 409 | Conflict | Action conflicts with current state (e.g., cards in escrow) |
| 429 | Rate Limit | Too many requests. Check Retry-After header |
| 500 | Server Error | Internal error. Contact support if persistent |
Error response shape
{
"error": "Missing required field: scryfall_id"
}Rate Limiting
Rate limit information is included in response headers.
| Header | Description |
|---|---|
| X-RateLimit-Limit | Maximum requests allowed in the window |
| X-RateLimit-Remaining | Requests remaining in the current window |
| X-RateLimit-Reset | Unix timestamp when the window resets |
| Retry-After | Seconds 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
Search for a card
GET /v1/cards/search?q=Lightning%20Bolt
- 2
Add to your wishlist
POST /v1/wishlist { "scryfall_id": "e2d1f9c2-...", "max_price": 2.00, "priority": 8 } - 3
Add cards to your collection
POST /v1/collection { "scryfall_id": "f5a2b8e1-...", "quantity": 2, "condition": "NM" } - 4
Compute matches
POST /v1/matches/compute
- 5
Review matches
GET /v1/matches?status=active
- 6
Request a trade
POST /v1/matches/abc123/request
- 7
Other user confirms the trade
POST /v1/matches/abc123/confirm
- 8
Mark trade as complete
POST /v1/matches/abc123/complete { "completed": true }