{
  "info": {
    "name": "Spellweave API v1",
    "description": "Complete collection for the Spellweave API. Includes OAuth 2.0 Authorization Code + PKCE flow and all v1 endpoints.\n\n## Setup\n1. Set `client_id` and `client_secret` in the collection variables\n2. Run \"1. Get Authorization Code\" in your browser (copy the code from the redirect)\n3. Paste the code into \"2. Exchange Code for Tokens\"\n4. All other requests will use the access token automatically\n\nSee https://dev.spellweave.app/docs for full documentation.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "variable": [
    {
      "key": "base_url",
      "value": "https://public-api.spellweave.app",
      "description": "API base URL (use http://localhost:5081 for local dev)"
    },
    {
      "key": "client_id",
      "value": "",
      "description": "Your app's client ID (starts with sw_)"
    },
    {
      "key": "client_secret",
      "value": "",
      "description": "Your app's client secret (starts with sw_secret_)"
    },
    {
      "key": "redirect_uri",
      "value": "https://oauth.pstmn.io/v1/callback",
      "description": "Postman's built-in OAuth callback URL"
    },
    {
      "key": "access_token",
      "value": "",
      "description": "Auto-populated after token exchange"
    },
    {
      "key": "refresh_token",
      "value": "",
      "description": "Auto-populated after token exchange"
    },
    {
      "key": "code_verifier",
      "value": "",
      "description": "Auto-generated PKCE verifier"
    },
    {
      "key": "authorization_code",
      "value": "",
      "description": "Paste the code from the browser redirect here"
    }
  ],
  "item": [
    {
      "name": "OAuth 2.0 Flow",
      "description": "Complete OAuth authorization flow. Run these in order.",
      "item": [
        {
          "name": "0. Discovery",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/.well-known/oauth-authorization-server",
            "description": "Fetch the OAuth server metadata. Shows supported endpoints, scopes, and grant types."
          }
        },
        {
          "name": "1. Get Authorization Code (open in browser)",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/api/v1/oauth/authorize?response_type=code&client_id={{client_id}}&redirect_uri={{redirect_uri}}&scope=profile:read collections:read collections:write decks:read decks:write&code_challenge={{code_challenge}}&code_challenge_method=S256&state=postman_test",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "oauth",
                "authorize"
              ],
              "query": [
                {
                  "key": "response_type",
                  "value": "code"
                },
                {
                  "key": "client_id",
                  "value": "{{client_id}}"
                },
                {
                  "key": "redirect_uri",
                  "value": "{{redirect_uri}}"
                },
                {
                  "key": "scope",
                  "value": "profile:read collections:read collections:write decks:read decks:write"
                },
                {
                  "key": "code_challenge",
                  "value": "{{code_challenge}}"
                },
                {
                  "key": "code_challenge_method",
                  "value": "S256"
                },
                {
                  "key": "state",
                  "value": "postman_test"
                }
              ]
            },
            "description": "Open this URL in your browser. Log in, approve the consent, then copy the `code` parameter from the redirect URL.\n\nBefore running: execute the Pre-request Script to generate PKCE values."
          },
          "event": [
            {
              "listen": "prerequest",
              "script": {
                "exec": [
                  "// Generate PKCE code_verifier (43-128 chars, URL-safe)",
                  "function base64url(buffer) {",
                  "    return btoa(String.fromCharCode(...new Uint8Array(buffer)))",
                  "        .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');",
                  "}",
                  "",
                  "const verifierBytes = new Uint8Array(32);",
                  "for (let i = 0; i < 32; i++) verifierBytes[i] = Math.floor(Math.random() * 256);",
                  "const codeVerifier = base64url(verifierBytes);",
                  "",
                  "// Generate code_challenge = base64url(SHA256(code_verifier))",
                  "const encoder = new TextEncoder();",
                  "const data = encoder.encode(codeVerifier);",
                  "crypto.subtle.digest('SHA-256', data).then(hash => {",
                  "    const codeChallenge = base64url(hash);",
                  "    pm.collectionVariables.set('code_verifier', codeVerifier);",
                  "    pm.collectionVariables.set('code_challenge', codeChallenge);",
                  "    console.log('PKCE code_verifier:', codeVerifier);",
                  "    console.log('PKCE code_challenge:', codeChallenge);",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "2. Exchange Code for Tokens",
          "request": {
            "method": "POST",
            "url": "{{base_url}}/api/v1/oauth/token",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/x-www-form-urlencoded"
              }
            ],
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "grant_type",
                  "value": "authorization_code"
                },
                {
                  "key": "client_id",
                  "value": "{{client_id}}"
                },
                {
                  "key": "client_secret",
                  "value": "{{client_secret}}"
                },
                {
                  "key": "code",
                  "value": "{{authorization_code}}"
                },
                {
                  "key": "redirect_uri",
                  "value": "{{redirect_uri}}"
                },
                {
                  "key": "code_verifier",
                  "value": "{{code_verifier}}"
                }
              ]
            },
            "description": "Exchange the authorization code for an access token and refresh token.\n\n**Before running:** Paste the authorization code from step 1 into the `authorization_code` collection variable."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "if (pm.response.code === 200) {",
                  "    const body = pm.response.json();",
                  "    pm.collectionVariables.set('access_token', body.access_token);",
                  "    pm.collectionVariables.set('refresh_token', body.refresh_token);",
                  "    console.log('Tokens saved. Access token expires in', body.expires_in, 'seconds.');",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "3. Refresh Tokens",
          "request": {
            "method": "POST",
            "url": "{{base_url}}/api/v1/oauth/token",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/x-www-form-urlencoded"
              }
            ],
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "grant_type",
                  "value": "refresh_token"
                },
                {
                  "key": "client_id",
                  "value": "{{client_id}}"
                },
                {
                  "key": "client_secret",
                  "value": "{{client_secret}}"
                },
                {
                  "key": "refresh_token",
                  "value": "{{refresh_token}}"
                }
              ]
            },
            "description": "Use the refresh token to get a new access token. The old refresh token is invalidated (rotation)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "if (pm.response.code === 200) {",
                  "    const body = pm.response.json();",
                  "    pm.collectionVariables.set('access_token', body.access_token);",
                  "    pm.collectionVariables.set('refresh_token', body.refresh_token);",
                  "    console.log('Tokens rotated successfully.');",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "4. Revoke Token",
          "request": {
            "method": "POST",
            "url": "{{base_url}}/api/v1/oauth/revoke",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/x-www-form-urlencoded"
              }
            ],
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "token",
                  "value": "{{access_token}}"
                },
                {
                  "key": "client_id",
                  "value": "{{client_id}}"
                },
                {
                  "key": "client_secret",
                  "value": "{{client_secret}}"
                }
              ]
            },
            "description": "Revoke an access or refresh token. Always returns 200 per RFC 7009."
          }
        }
      ]
    },
    {
      "name": "API v1 Endpoints",
      "description": "All API v1 endpoints. Requires a valid access token (run the OAuth flow first).",
      "item": [
        {
          "name": "Get My Profile",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/api/v1/me",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{access_token}}"
              }
            ],
            "description": "Returns the authenticated user's profile.\nScope: profile:read"
          }
        },
        {
          "name": "List Collections",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/api/v1/collections?limit=20",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "collections"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "20"
                },
                {
                  "key": "cursor",
                  "value": "",
                  "disabled": true
                }
              ]
            },
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{access_token}}"
              }
            ],
            "description": "List the user's card collections.\nScope: collections:read\n\nSupports cursor-based pagination via `cursor` and `limit` params."
          }
        },
        {
          "name": "Create Collection",
          "request": {
            "method": "POST",
            "url": "{{base_url}}/api/v1/collections",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{access_token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"API Import Test\",\n  \"cards\": [\n    { \"name\": \"Sol Ring\", \"quantity\": 1 },\n    { \"name\": \"Command Tower\", \"quantity\": 1 }\n  ]\n}"
            },
            "description": "Create a new collection with optional cards.\nScope: collections:write\n\nMax 500 cards per request. Respects plan capacity limits."
          }
        },
        {
          "name": "Import Cards",
          "request": {
            "method": "POST",
            "url": "{{base_url}}/api/v1/collections/:collection_id/cards",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{access_token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"cards\": [\n    { \"name\": \"Lightning Bolt\", \"quantity\": 4 },\n    { \"name\": \"Counterspell\", \"quantity\": 2 }\n  ]\n}"
            },
            "description": "Add cards to an existing collection (upsert).\nScope: collections:write\n\nMax 500 cards per request. Cards already in the collection get their quantity incremented."
          }
        },
        {
          "name": "Get Collection",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/api/v1/collections/:collection_id",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "collections",
                ":collection_id"
              ],
              "query": [
                {
                  "key": "color",
                  "value": "G",
                  "disabled": true,
                  "description": "Comma-separated: W, U, B, R, G, or C for colourless. Values are OR-ed."
                },
                {
                  "key": "color_match",
                  "value": "identity",
                  "disabled": true,
                  "description": "identity (default, Commander legality) or colors (the card's own casting colours). Sending this without color returns 400."
                },
                {
                  "key": "type",
                  "value": "creature",
                  "disabled": true,
                  "description": "Comma-separated: artifact, battle, creature, enchantment, instant, kindred, land, planeswalker, sorcery. OR-ed. Matches the front face and the types only, so a transforming Saga is an enchantment. Subtypes are rejected."
                },
                {
                  "key": "limit",
                  "value": "100",
                  "disabled": true,
                  "description": "Cards per page, 1-500."
                },
                {
                  "key": "cursor",
                  "value": "",
                  "disabled": true,
                  "description": "next_cursor from the previous response."
                }
              ]
            },
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{access_token}}"
              }
            ],
            "description": "Get a specific collection with its cards.\nScope: collections:read\n\nReplace :collection_id with a real UUID from List Collections."
          }
        },
        {
          "name": "List Decks",
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{base_url}}/api/v1/decks?limit=20",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "decks"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "20"
                },
                {
                  "key": "cursor",
                  "value": "",
                  "disabled": true
                }
              ]
            },
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{access_token}}"
              }
            ],
            "description": "List the user's decks.\nScope: decks:read\n\nSupports cursor-based pagination."
          }
        },
        {
          "name": "Get Deck",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/api/v1/decks/:deck_id",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{access_token}}"
              }
            ],
            "description": "Get a specific deck with all its cards.\nScope: decks:read\n\nReplace :deck_id with a real UUID from List Decks."
          }
        },
        {
          "name": "Get Deck Statistics",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/api/v1/decks/:deck_id/stats?include=all",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{access_token}}"
              }
            ],
            "description": "Card counts by type, land count and average mana value.\n\n?include= adds the deeper sections Spellweave computes for the deck page:\n  fundamentals  archetype + per-role actual/target/gap\n  curve         the mana curve histogram\n  bracket       Commander bracket, game changers, detected combos\n  all           all of the above\n\nComma-separated. An unrecognised value returns 400 invalid_include with the\nsupported list, rather than being ignored.\n\nOmitting include returns exactly the response this endpoint always returned.\nResponses that include a section also carry an `attribution` block: if you\ndisplay this data you must disclose that it comes from spellweave.app.\n\nScope: decks:read"
          }
        },
        {
          "name": "Import a Deck",
          "request": {
            "method": "POST",
            "url": "{{base_url}}/api/v1/decks",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{access_token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"deck\": \"https://moxfield.com/decks/REPLACE_WITH_A_REAL_DECK\",\n  \"format\": \"commander\",\n  \"name\": \"Imported from Postman\"\n}"
            },
            "description": "Imports a deck into the authenticated user's account.\n\n`deck` accepts EITHER a decklist or a Moxfield/Archidekt deck URL. Both go through\nthe same parser and validation the Spellweave app uses.\n\n`format` defaults to commander. `standard` is recognised but returns\n400 format_not_yet_supported.\n\nCommander decks must be exactly 100 cards including the commander (98 + 2 partners\nis also 100). Failures carry a machine-readable code, and size failures carry a\n`details` object with the numbers needed to fix the deck:\n\n  { \"code\": \"INVALID_DECK_SIZE\",\n    \"details\": { \"totalCards\": 98, \"expected\": 100, \"delta\": 2,\n                 \"direction\": \"add\", \"commanderZoneCount\": 1 } }\n\nNote `resolved_cards` in the response: it can be lower than `requested_cards`, and\n`unresolved_cards` lists what did not make it in.\n\nScope: decks:write"
          }
        },
        {
          "name": "Get Deck Lab (paid)",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/api/v1/decks/:deck_id/lab",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{access_token}}"
              }
            ],
            "description": "The Deck Lab goldfish simulation: speed timeline, representative game and\ndistributions over a thousand simulated games.\n\nPAID FEATURE, per deck. Returns 403 lab_not_unlocked with an `unlock_url` unless\nthe deck owner has run an AI Analysis or completed a Deck Tune on spellweave.app.\nA deck that is unlocked but never generated returns 404 lab_not_generated.\n\nScope: decks:read"
          }
        },
        {
          "name": "Deck Analysis (not implemented)",
          "request": {
            "method": "POST",
            "url": "{{base_url}}/api/v1/decks/:deck_id/analysis",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{access_token}}"
              }
            ],
            "description": "Placeholder. Returns 501 not_implemented today, so you can tell 'not built yet'\nfrom a bad request while building against the API.\n\nAI Analysis is a paid feature on spellweave.app. Exposing it here is planned."
          }
        },
        {
          "name": "Deck Tune (not implemented)",
          "request": {
            "method": "POST",
            "url": "{{base_url}}/api/v1/decks/:deck_id/tune",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{access_token}}"
              }
            ],
            "description": "Placeholder. Returns 501 not_implemented today.\n\nDeck Tune is a paid feature on spellweave.app. Exposing it here is planned."
          }
        },
        {
          "name": "User Info (OAuth)",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/api/v1/oauth/userinfo",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{access_token}}"
              }
            ],
            "description": "OAuth UserInfo endpoint. Returns user ID and granted scopes.\nScope: profile:read"
          }
        }
      ]
    }
  ]
}
