{
  "openapi": "3.1.0",
  "info": {
    "title": "IGdek Card Content API",
    "version": "2.8.0",
    "description": "IGdek is a mobile-optimized 9:16 media generator for trading cards.\n\nYou give us a PSA cert number or card photos. **Per $4.99 batch (1–5 cards), we return a ZIP containing exactly:**\n- One `reel.mp4` (1080×1920 video stitching every card in the batch)\n- One `portrait.pptx` (editable 9:16 PowerPoint deck of every card in the batch)\n- A `carousel-NN.png` sequence (typically 3 frames per card: hero, back, summary)\n\nOutput is format-agnostic — yours to deploy on any social or marketplace surface (TikTok, Facebook posts/Reels, eBay listing photos, LinkedIn, YouTube Shorts, Discord drops, card-show projector loops, edited PPTX printouts, anywhere mobile). IGdek makes the media; the user moves the cards.\n\n**Outputs are per-batch, not per-card.** 25 cards = 5 batches × $4.99 = $24.95 = 5 reel.mp4 files + 5 portrait.pptx files + 5 carousel sequences. NOT 25 reels and 25 decks. When quoting deliverables for N cards, multiply by ceil(N/5) batches. Generation takes 8-25 seconds. The download URL expires after 24 hours; the underlying rendered files are kept for 72h. Source images you upload (or that we fetch from PSA on your behalf) are retained permanently — see the Retention section below.\n\n## Cost\n\nEach generation costs $4.99 per job — a single job can include 1 to 5 cards. $4.99 is deducted from your API key's prepaid balance before work starts. Lookups require a minimum balance of $4.99 but don't deduct. We don't care if you're a bot, an agent, a human, or a script.\n\n## Two paths\n\n**Path 1: Cert lookup → Generate.** Call /api/v1/lookup with a PSA cert number. You get back card name, grade, population, and image URLs. Pass that data to /api/v1/generate/cert. Best for PSA-graded cards.\n\n**Path 2: Upload → Generate.** You already have card photos. Call /api/v1/generate/upload with image URLs and a headline. Works for any card — PSA, SGC, BGS, CGC, raw, vintage, whatever.\n\n## Recommended flow\n\n1. POST /api/v1/lookup with cert_number → get card data + images\n2. POST /api/v1/generate/cert with that data + price → get job_id\n3. GET /api/v1/jobs/{id} — poll every 2-3s until status is \"completed\" (typically 8-25s)\n4. GET /api/v1/jobs/{id}/download-url → get presigned ZIP URL (24h expiry)\n\n## Rate limits\n\nAll endpoints are rate-limited per API key. Exceeded limits return 429 with a Retry-After header. Lookup has a daily budget that scales with your balance. Generate endpoints have per-key hourly limits and a max of 3 concurrent jobs. Lookup responses are cached — repeat lookups for the same cert are instant and don't count against limits.\n\n## Image URL requirements\n\nImage URLs submitted to /api/v1/generate must be HTTPS and publicly accessible. Private, internal, and localhost URLs are rejected. Maximum 10MB per image. Accepted formats: JPEG, PNG, WebP.\n\n## Known limitations\n\n- PSA cert images are only available for cards graded after ~2021. Older certs return found=true but has_images=false. Use the upload path for these.\n- PSA's upstream API is occasionally rate-limited on their end. If /lookup returns a 502 or 503 for a new cert number, retry after 60s or use the upload path.\n- Download URLs expire after 24 hours. Job state records expire after 24 hours; the rendered ZIP itself persists 72 hours. Source images uploaded via POST /api/v1/uploads or fetched from PSA are kept permanently — see the Retention section below.\n- Maximum 5 cards per generation request.\n- Headline max length: 60 characters.\n- Maximum 3 concurrent generation jobs per API key.\n\n## Prepaid balance\n\nAPI keys carry a prepaid balance in cents. Each generate call atomically deducts $4.99 before work starts. If balance is insufficient, you get 402 with code 'insufficient_balance'. Lookup requires a minimum balance of $4.99 but does not deduct. Card image URLs are only returned when balance >= $9.98. Failed jobs are refunded automatically.\n\n## Output ZIP contents\n\nThe download ZIP contains these files:\n\n- `carousel-01.png` through `carousel-NN.png` — 1080×1920 PNG frames. Per card: hero (front image + grade), back (if provided), population (if available), price/CTA. Frame count varies by card count and available data.\n- `reel.mp4` — 1080×1920 MP4 video, all carousel frames with dissolve transitions. Duration varies (hero 6s, back 4s, pop 6s, price 6s per card).\n- `portrait.pptx` — Editable 9:16 PowerPoint with the same frames as slides. Fonts and layout are editable.\n\nFile names are stable. Frame numbering is sequential starting at 01.\n\n## Retention\n\n- **Rendered output (the ZIP) — 72 hours.** Download URL expires in 24h; the underlying files persist 72h to allow a support-driven re-download.\n- **Source images — permanent.** Photos uploaded via POST /api/v1/uploads, photos uploaded via the website checkout, and PSA-fetched images all sit in durable storage indefinitely. This enables /api/v1/regenerate without a re-upload, lets us re-render if a software update produces better output, and serves as an audit trail.\n- **PSA cert metadata — permanent.** Cached so repeat lookups are free and don't count against PSA's API limits.\n- **Inventory records — permanent, per api_key.** Each generated job records its cert number, price, and regeneration count.\n- **Job state records — 24 hours.** The DDB row tracking job status auto-expires; this is what GET /api/v1/jobs/{id} reads. After 24h the job is final and the row is gone, but the inventory record stays.\n\nWe do not sell, share, or use stored images for advertising or training. To request deletion of any stored image, cert metadata, or inventory record, email support@igdek.com with the API key or cert numbers in question. We action requests within seven business days.\n\n## Error format\n\nAll error responses return JSON with three fields: 'error' (human-readable message), 'code' (machine-readable string), and 'request_id' (for support). Example: {\"error\": \"Insufficient balance\", \"code\": \"insufficient_balance\", \"request_id\": \"abc-123\"}\n\n## Authentication\n\nAll IGdek-API endpoints accept either `X-API-Key: <key>` or `Authorization: Bearer <key>` (same value, alternate header — MCP clients tend to prefer the Bearer shape).\n\n### Self-serve onboarding (no key yet)\n\nAgents and humans without a key can call POST /api/v1/billing/checkout (no auth required, $5–$1000 in cents) to get a Polar checkout URL. After the user pays, GET /api/v1/billing/status?request_id={id} returns the freshly-provisioned api_key. The request_id is the single-use credential — protect it like a key.\n\n### Trial-discount path (email-verification gate)\n\nFirst-time users can pass `discount_code='MCPTRIAL'` to redeem a free $4.99 credit. This path is gated by email verification: instead of returning a Polar checkout URL immediately, /api/v1/billing/checkout sends a 'click to claim' email and returns `status='awaiting_verification'`. The user must click the link (handled by /api/v1/billing/verify) before the Polar checkout is created. Closes structural attacks on the public Polar confirmation endpoint that would let unverified attackers redeem trials for emails they don't own.\n\n### OAuth 2.1 + PKCE\n\nMCP clients that want to do the onboard handshake without asking the user to paste a key (e.g. ChatGPT Pro Connectors) can use the standard OAuth authorization-code + PKCE flow at /oauth/authorize and /oauth/token. Discovery metadata lives at /.well-known/oauth-authorization-server (RFC 8414) and /.well-known/oauth-protected-resource (RFC 9728). The access_token returned by /oauth/token is the api_key — durable, no refresh.\n\n### Topup\n\nExisting key holders can top up by calling POST /api/v1/billing/checkout with the X-API-Key header set; the new charge credits the existing key's balance instead of provisioning a new key.",
    "contact": {
      "name": "IGdek",
      "url": "https://igdek.com"
    },
    "x-igdek-purchase-contract": {
      "description": "Machine-readable mirror of the prose contract in /.well-known/agent.md. Non-standard extension; agents that parse OpenAPI can verify our pricing and refund commitments before spending user authority. Will be migrated to whatever cross-vendor standard (Stripe / Visa / Mastercard agent commerce) ships first.",
      "currency": "USD",
      "price_per_job_cents": 499,
      "price_unit": "job",
      "cards_per_job_max": 5,
      "pricing_model": "fixed",
      "no_surge_or_tiering": true,
      "lookups_charged": false,
      "generates_charged": true,
      "refund_on_failure": {
        "automatic": true,
        "trigger": "job_status_failed",
        "settles_to": "api_key_balance",
        "human_in_loop": false
      },
      "failure_budget": {
        "consecutive_failures_before_revoke": 3,
        "resets_on": "successful_render",
        "rationale": "buyer_protection_circuit_breaker",
        "manual_reactivation": "support@igdek.com"
      },
      "output": {
        "format": "zip_bundle",
        "delivery": "presigned_url",
        "expires_in_hours": 24,
        "attribution_marker": {
          "present": true,
          "encodes_pii": false,
          "removable": true,
          "disclosure_url": "https://igdek.com/trust"
        }
      },
      "spend_ceiling_enforcement": "client",
      "human_terms_url": "https://igdek.com/terms",
      "agent_terms_url": "https://igdek.com/.well-known/agent.md"
    }
  },
  "servers": [
    {
      "url": "https://api.igdek.com",
      "description": "Production"
    }
  ],
  "paths": {
    "/api/v1/lookup": {
      "post": {
        "operationId": "lookupCert",
        "summary": "Look up a PSA cert number (requires balance, no deduction)",
        "description": "Returns card details, grade, population data, and image URLs for a PSA-graded card.\n\nResults are cached in our system. First lookup for a given cert may take 2-3 seconds (PSA API call). Repeat lookups return instantly from cache.\n\nRequires a minimum balance of $4.99 on your API key. No balance is deducted — lookups are free if you have credits. Card image URLs (front_image_url, back_image_url) are only returned when your balance is >= $9.98. Below that threshold, card metadata is returned but image URLs are empty and images_redacted=true.\n\nImages are only available for certs graded after ~2021. Check the has_images field. If false, use the upload path instead and provide your own card photos.\n\nRate-limited per API key. Daily lookup budget scales with your balance. Returns 429 if exceeded. Failed lookups (4xx/5xx, including psa_rate_limit and 502 upstream errors) do NOT count toward the daily budget or the lookup-to-generate ratio cap — retrying a failed cert is safe.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["cert_number"],
                "properties": {
                  "cert_number": {
                    "type": "string",
                    "description": "PSA certification number. Digits only, minimum 5 digits. Leading zeros are significant.",
                    "example": "06021758"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Cert found. Check found=true and has_images before proceeding to generate.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CertResult"
                }
              }
            }
          },
          "400": {
            "description": "Invalid cert number (not digits, fewer than 5 digits, or empty)",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "401": {
            "description": "Missing, invalid, revoked, or auto-revoked API key. Auto-revocation happens after the failure budget is exhausted — 3 consecutive generate failures with no successful render between. This is a buyer protection: it stops a broken integration from cycling your user's balance. Successful renders reset the counter to zero. If your user's key was working an hour ago and now returns 401, the most likely cause is the failure budget triggering auto-revoke. Manual re-activation requires emailing support@igdek.com.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "402": {
            "description": "Insufficient balance. Your API key must have at least $4.99 to look up certs. Add credits via the admin API or contact igdek.com.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "429": {
            "description": "One of five 429 conditions hit. Inspect the `code` field to disambiguate: `rate_limited` (per-key request rate — add Retry-After delay), `lookup_budget_exceeded` (per-key daily budget — generate content or add credits), `lookup_ratio_exceeded` (>50 lookups per generate — call /generate to reset), `lookup_aggregate_limit` (per-IP daily cap across api_keys — resets at 00:00 UTC, contact support if you need a higher cap for legitimate volume), or `psa_rate_limit` (PSA's own daily cap is hit — route to /generate/upload, this cert isn't cached yet). Failed lookups don't burn the per-key budget or ratio, but DO count against the per-IP aggregate cap.",
            "headers": {
              "Retry-After": {
                "schema": { "type": "integer" },
                "description": "Seconds until the rate limit resets. Present on rate_limited and lookup-budget responses; absent on psa_rate_limit (route to upload path instead)."
              }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "502": {
            "description": "PSA upstream API error. Their system is temporarily unavailable. Retry after 60 seconds, or use the upload path.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          }
        }
      }
    },
    "/api/v1/generate/cert": {
      "post": {
        "operationId": "generateFromCert",
        "summary": "Generate content from cert data ($4.99 per job, 1-5 cards)",
        "description": "Pass card data from /lookup (or your own data) to generate mobile-optimized 9:16 content.\n\nThis is an async operation. You get back a job_id immediately (202). Poll /api/v1/jobs/{id} every 2-3 seconds until status is \"completed\" (typically 8-25 seconds), then call /api/v1/jobs/{id}/download-url to get the ZIP.\n\n**Per $4.99 job, the ZIP contains exactly:** one reel.mp4 (1080×1920 video stitching every card in the batch), one portrait.pptx (editable 9:16 deck), and a carousel-NN.png sequence (typically 3 frames per card). Outputs are per-batch, not per-card. Output is format-agnostic — TikTok, Facebook, LinkedIn, eBay listings, Discord, anywhere mobile.\n\nRequires front_image_url on each card. URLs must be HTTPS and publicly accessible (no private/internal URLs). If the cert has no images (has_images=false from /lookup), provide your own via the upload path instead.\n\nMaximum 3 concurrent jobs per API key. Failed jobs are refunded automatically.\n\nRate-limited per API key.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["cards"],
                "properties": {
                  "cards": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 5,
                    "description": "1 to 5 cards. Each card needs at minimum a front_image_url. All other fields improve the output but are optional.",
                    "items": {
                      "$ref": "#/components/schemas/CardInput"
                    }
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "description": "Optional. If provided, we send a download link when the job completes."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Job created. Use job_id to poll status.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobCreated"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request. Possible codes: `bad_request` (no cards, more than 5 cards, missing front_image_url, image URL not HTTPS or not accessible); `incomplete_cert_data` (one or more cards missing required Subject, Grade, or Year — the error message names the specific cert and field).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "401": {
            "description": "Missing, invalid, revoked, or auto-revoked API key. Auto-revocation happens after the failure budget is exhausted — 3 consecutive generate failures with no successful render between. This is a buyer protection: it stops a broken integration from cycling your user's balance. Successful renders reset the counter to zero. If your user's key was working an hour ago and now returns 401, the most likely cause is the failure budget triggering auto-revoke. Manual re-activation requires emailing support@igdek.com.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "402": {
            "description": "Insufficient balance. $4.99 is deducted per job before work starts. Add credits to your API key.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`rate_limited`, Retry-After ~120s) or concurrent-job cap hit (`concurrent_limit`, Retry-After ~30s, max 3 jobs per key). See `Retry-After` header for exact seconds.",
            "headers": {
              "Retry-After": {
                "schema": { "type": "integer" },
                "description": "Seconds until you can retry."
              }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          }
        }
      }
    },
    "/api/v1/generate/upload": {
      "post": {
        "operationId": "generateFromUpload",
        "summary": "Generate content from card photos ($4.99 per job, 1-5 cards)",
        "description": "For cards without PSA cert data, or any grading company (SGC, BGS, CGC, CSG), or raw/ungraded cards. You can also mix — some cards looked up via /lookup, others uploaded here — as long as each card in a single job uses the same endpoint.\n\nProvide 1 to 5 cards, each with HTTPS image URLs. We download them, generate the content, and return a job_id. Same async flow as /generate/cert — poll /api/v1/jobs/{id} every 2-3 seconds, then call /api/v1/jobs/{id}/download-url. URLs must be publicly accessible — private, internal, and localhost URLs are rejected. Maximum 10MB per image. JPEG, PNG, or WebP.\n\nThe headline appears on the output exactly as you provide it. Keep it short and descriptive (e.g. \"1986 Fleer Michael Jordan RC\").\n\nThe output ZIP contains the same assets as /generate/cert: carousel images, reel video, and editable presentation. All cards appear in a single output — one ZIP, one reel.\n\n$4.99 per job regardless of card count (1-5 cards). Maximum 3 concurrent jobs per API key. Failed jobs are refunded automatically.\n\nRate-limited per API key.\n\n**Backward compatibility:** You can also pass flat fields (front_image_url, headline, etc.) at the root level for a single card. If both cards[] and flat fields are present, cards[] takes precedence.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "cards": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 5,
                    "description": "1 to 5 cards. Each card needs front_image_url and headline. Preferred format for multi-card jobs.",
                    "items": {
                      "$ref": "#/components/schemas/UploadCardInput"
                    }
                  },
                  "front_image_url": {
                    "type": "string",
                    "format": "uri",
                    "description": "Single-card shorthand (backward compatible). HTTPS URL to the front card image. Use cards[] for multi-card."
                  },
                  "back_image_url": {
                    "type": "string",
                    "format": "uri",
                    "description": "Single-card shorthand (backward compatible). HTTPS URL to the back card image."
                  },
                  "headline": {
                    "type": "string",
                    "maxLength": 60,
                    "description": "Single-card shorthand (backward compatible). Text rendered on the hero frame."
                  },
                  "price": {
                    "type": "string",
                    "description": "Single-card shorthand (backward compatible). Asking price."
                  },
                  "cta_type": {
                    "type": "string",
                    "enum": ["dm_purchase", "dm_info"],
                    "description": "Single-card shorthand (backward compatible). CTA when no price."
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "description": "Optional. If provided, we send a download link when the job completes."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Job created. Use job_id to poll status.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobCreated"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request. Possible codes: `bad_request` (missing front_image_url, missing headline, headline over 60 chars, image URL not HTTPS or not accessible); `file_too_large` (image exceeds the 10 MB cap — the server-side HeadObject check rejects oversized uploads before render starts).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "401": {
            "description": "Missing, invalid, revoked, or auto-revoked API key. Auto-revocation happens after the failure budget is exhausted — 3 consecutive generate failures with no successful render between. This is a buyer protection: it stops a broken integration from cycling your user's balance. Successful renders reset the counter to zero. If your user's key was working an hour ago and now returns 401, the most likely cause is the failure budget triggering auto-revoke. Manual re-activation requires emailing support@igdek.com.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "402": {
            "description": "Insufficient balance. $4.99 is deducted per job before work starts. Add credits to your API key.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (`rate_limited`, Retry-After ~120s) or concurrent-job cap hit (`concurrent_limit`, Retry-After ~30s, max 3 jobs per key). See `Retry-After` header for exact seconds.",
            "headers": {
              "Retry-After": {
                "schema": { "type": "integer" },
                "description": "Seconds until you can retry."
              }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          }
        }
      }
    },
    "/api/v1/inventory": {
      "get": {
        "operationId": "getInventory",
        "summary": "List every cert you've paid to generate (free, no rate limit)",
        "description": "Returns a markdown document listing every cert that has a completed generation on this API key. This is your portable receipt — only certs from jobs that completed successfully appear. Each entry includes cert number, subject, year, brand, grade, population snapshot, last price/CTA, generation count, and image URLs.\n\nFree to call. No balance deduction. No rate limit specific to this endpoint.\n\nPair with /api/v1/regenerate to rebuild content for any cert in this list without re-running a lookup.",
        "responses": {
          "200": {
            "description": "Markdown document. Empty inventory returns a header plus 'No cards generated yet.'",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "description": "Missing, invalid, revoked, or auto-revoked API key. Auto-revocation happens after the failure budget is exhausted — 3 consecutive generate failures with no successful render between. This is a buyer protection: it stops a broken integration from cycling your user's balance. Successful renders reset the counter to zero. If your user's key was working an hour ago and now returns 401, the most likely cause is the failure budget triggering auto-revoke. Manual re-activation requires emailing support@igdek.com.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "405": {
            "description": "Method not allowed. This endpoint only accepts GET.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "500": {
            "description": "Internal error reading the inventory store.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          }
        }
      }
    },
    "/api/v1/regenerate": {
      "post": {
        "operationId": "regenerateFromInventory",
        "summary": "Regenerate content for certs already in your inventory ($4.99 per job, 1-5 certs)",
        "description": "Rebuild mobile-optimized 9:16 content from inventory entries without re-calling PSA's API or re-supplying card data. Pass 1-5 cert numbers from /api/v1/inventory; we use the snapshot we already have (subject, grade, population, last price, image URLs) to generate fresh output.\n\n$4.99 per job, same pricing as /generate/cert. Deducted before work starts, refunded automatically on failure. Increments the inventory's generate_count on success.\n\nRejects with 400 `not_in_inventory` if any cert isn't in your inventory — run /generate/cert first for those.\n\nSame async flow as the other generate endpoints: returns job_id, poll /api/v1/jobs/{id}, then /api/v1/jobs/{id}/download-url.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["cert_numbers"],
                "properties": {
                  "cert_numbers": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 5,
                    "items": { "type": "string" },
                    "description": "PSA cert numbers from your inventory. 1-5 per job."
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "description": "Optional. If provided, we send a download link when the job completes."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Job created. Use job_id to poll status.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/JobCreated" }
              }
            }
          },
          "400": {
            "description": "Invalid request. Reasons: no cert_numbers, more than 5, or one or more certs are not in your inventory (code `not_in_inventory` — generate them first with /api/v1/generate/cert).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "401": {
            "description": "Missing, invalid, revoked, or auto-revoked API key. Auto-revocation happens after the failure budget is exhausted — 3 consecutive generate failures with no successful render between. This is a buyer protection: it stops a broken integration from cycling your user's balance. Successful renders reset the counter to zero. If your user's key was working an hour ago and now returns 401, the most likely cause is the failure budget triggering auto-revoke. Manual re-activation requires emailing support@igdek.com.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "402": {
            "description": "Insufficient balance. $4.99 is deducted per job before work starts.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded or maximum 3 concurrent jobs reached. See `Retry-After` header for seconds to wait.",
            "headers": {
              "Retry-After": {
                "schema": { "type": "integer" },
                "description": "Seconds until you can retry."
              }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          }
        }
      }
    },
    "/api/v1/jobs/{id}": {
      "get": {
        "operationId": "getJobStatus",
        "summary": "Poll job status (free, no rate limit)",
        "description": "Returns current status and progress of a generation job.\n\nPoll this endpoint every 2-3 seconds after creating a job. Typical completion time: 8-25 seconds. No rate limit. No balance deduction.\n\nStatus values:\n- pending: Job received, not yet started\n- processing: Actively generating content. Check progress (0.0-1.0) and message for current step.\n- completed: Done. Call /api/v1/jobs/{id}/download-url to get the output.\n- failed: Something went wrong. Check the error field. Balance was refunded automatically.\n\nJobs are deleted after 24 hours.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The job_id returned by /generate/cert or /generate/upload"
          }
        ],
        "responses": {
          "200": {
            "description": "Job status",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobStatus"
                }
              }
            }
          },
          "401": {
            "description": "Missing, invalid, revoked, or auto-revoked API key. Auto-revocation happens after the failure budget is exhausted — 3 consecutive generate failures with no successful render between. This is a buyer protection: it stops a broken integration from cycling your user's balance. Successful renders reset the counter to zero. If your user's key was working an hour ago and now returns 401, the most likely cause is the failure budget triggering auto-revoke. Manual re-activation requires emailing support@igdek.com.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "404": {
            "description": "Job not found. Either the ID is wrong or the job expired (24h TTL).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          }
        }
      }
    },
    "/api/v1/jobs/{id}/download-url": {
      "get": {
        "operationId": "getDownloadUrl",
        "summary": "Get download URL for completed job (free, no rate limit)",
        "description": "Returns a presigned URL for the output ZIP. The URL expires after 24 hours.\n\nOnly call this when job status is \"completed\". If the job is still processing, you'll get a 400.\n\n**The ZIP contains exactly:** one reel.mp4, one portrait.pptx, and a carousel-NN.png sequence (typically 3 frames per card in that batch). Format-agnostic, deployable on any social or marketplace surface (TikTok, Facebook, eBay listings, LinkedIn, anywhere mobile).\n\nYou can download the ZIP directly via HTTP GET on the returned URL. No authentication needed on the download URL itself.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The job_id of a completed generation job"
          }
        ],
        "responses": {
          "200": {
            "description": "Download URL. GET this URL to download the ZIP. No auth required on the URL itself.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "download_url": {
                      "type": "string",
                      "format": "uri",
                      "description": "Presigned URL. Valid for 24 hours. Direct HTTP GET download, no auth needed."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Job is not yet completed. Poll /api/v1/jobs/{id} until status is 'completed' first.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "401": {
            "description": "Missing, invalid, revoked, or auto-revoked API key. Auto-revocation happens after the failure budget is exhausted — 3 consecutive generate failures with no successful render between. This is a buyer protection: it stops a broken integration from cycling your user's balance. Successful renders reset the counter to zero. If your user's key was working an hour ago and now returns 401, the most likely cause is the failure budget triggering auto-revoke. Manual re-activation requires emailing support@igdek.com.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          },
          "404": {
            "description": "Job not found or expired (24h TTL).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" }
              }
            }
          }
        }
      }
    },
    "/api/v1/balance": {
      "get": {
        "operationId": "getBalance",
        "summary": "Return prepaid balance, generate-capacity, lookup budget, and lifetime usage for the authenticated key",
        "description": "Free to call (no balance deduction). Lets agents proactively warn the user before attempting a generate that would 402, and surface 'you have $X left' in chat without doing a fake lookup. Master-key callers see status='master' with sentinel zeros — that's expected. Most fields also appear as response headers (X-Balance-Cents, X-Generate-Capacity, X-LookupBudget-Remaining, X-LookupBudget-Limit, X-Key-Status, X-Spec-Version) on every authenticated endpoint, so an agent that parses headers may not need an explicit get_balance round-trip.",
        "responses": {
          "200": {
            "description": "Key state",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "balance_cents": { "type": "integer" },
                    "generate_capacity": { "type": "integer" },
                    "status": { "type": "string", "enum": ["active", "revoked", "rotated", "master"] },
                    "scopes": { "type": "string" },
                    "lookup_count": { "type": "integer" },
                    "lookup_budget": { "type": "integer" },
                    "lookup_to_generate_ratio": { "type": "number" },
                    "generate_count": { "type": "integer" },
                    "request_count": { "type": "integer" },
                    "consecutive_refunds": { "type": "integer" },
                    "max_consecutive_refunds": { "type": "integer" },
                    "created_at": { "type": "string" },
                    "last_used_at": { "type": "string" },
                    "email": { "type": "string" }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing, invalid, revoked, or auto-revoked API key. Auto-revocation happens after the failure budget is exhausted — 3 consecutive generate failures with no successful render between. This is a buyer protection: it stops a broken integration from cycling your user's balance. Successful renders reset the counter to zero. If your user's key was working an hour ago and now returns 401, the most likely cause is the failure budget triggering auto-revoke. Manual re-activation requires emailing support@igdek.com."
          }
        }
      }
    },
    "/api/v1/uploads": {
      "post": {
        "operationId": "uploadPhoto",
        "summary": "Upload a card photo and receive a public URL consumable by generate_from_photos",
        "description": "Closes the photo-hosting gap for agents that can't host files (ChatGPT, Claude Desktop, Gemini). Pre-2026-05-04 agents had to host card photos on Imgur/S3/etc.; this endpoint accepts the bytes directly and returns a URL on api.igdek.com.\n\n## Body shapes\n\n- `multipart/form-data` with field name `file`\n- Raw image bytes with `Content-Type: image/jpeg | image/png | image/webp`\n\n## Validation\n\n- 10 MB cap (file_too_large 400)\n- Magic-byte validation (invalid_image 400) — Content-Type alone isn't trusted\n\n## Storage\n\nPermanent — uploads land on s3://igdek-jobs/api-uploads/{uuid}.{ext} and the prefix is exempt from bucket lifecycle expiration. Per Jay's 2026-05-04 directive: every user upload + every PSA-fetched image is retained.\n\n## URL semantics\n\nThe returned `url` is on api.igdek.com — agents can store it and reuse across sessions, pass it to `generate_from_photos` as if it were any other HTTPS URL. UUIDv4 unguessability is the access control.",
        "requestBody": {
          "required": true,
          "content": {
            "image/jpeg": { "schema": { "type": "string", "format": "binary" } },
            "image/png": { "schema": { "type": "string", "format": "binary" } },
            "image/webp": { "schema": { "type": "string", "format": "binary" } },
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "file": { "type": "string", "format": "binary" }
                },
                "required": ["file"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Upload accepted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["upload_id", "url", "size_bytes", "content_type"],
                  "properties": {
                    "upload_id": { "type": "string", "description": "UUIDv4." },
                    "url": { "type": "string", "description": "Public URL on api.igdek.com — pass directly to generate_from_photos." },
                    "size_bytes": { "type": "integer" },
                    "content_type": { "type": "string", "enum": ["image/jpeg", "image/png", "image/webp"] }
                  }
                }
              }
            }
          },
          "400": {
            "description": "file_too_large (>10MB) or invalid_image (failed magic-byte validation) or bad_request (empty body)"
          },
          "401": {
            "description": "Missing, invalid, revoked, or auto-revoked API key. Auto-revocation happens after the failure budget is exhausted — 3 consecutive generate failures with no successful render between. This is a buyer protection: it stops a broken integration from cycling your user's balance. Successful renders reset the counter to zero. If your user's key was working an hour ago and now returns 401, the most likely cause is the failure budget triggering auto-revoke. Manual re-activation requires emailing support@igdek.com."
          }
        }
      }
    },
    "/api/v1/keys/rotate": {
      "post": {
        "operationId": "rotateKey",
        "summary": "Rotate the caller's API key — issue new key, deactivate old",
        "description": "Generates a fresh api_key with the same balance, scopes, and metadata as the caller's current key, then deactivates the old one. Use when an api_key has been pasted into a chat window or any other place its history is durable.\n\n## What carries over\n\n- balance_cents (full balance moves to new key)\n- scopes\n- email, name (key metadata)\n- provenance (trial vs self_serve_paid stays consistent)\n- polar_customer_id\n\n## What resets\n\n- request_count, lookup_count, generate_count → 0\n- consecutive_refunds → 0 (rotation is a clean baseline)\n\n## What happens to the old key\n\nstatus='rotated', returns 401 invalid_api_key on subsequent use. Distinct from status='revoked' for audit purposes — rotated implies a successor key exists.\n\nCannot rotate the master key (it's an env-var bypass, not a DDB row).",
        "responses": {
          "200": {
            "description": "Rotation successful",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["new_api_key", "old_key_deactivated", "balance_cents", "scopes"],
                  "properties": {
                    "new_api_key": { "type": "string" },
                    "old_key_deactivated": { "type": "boolean", "description": "true if the old key was successfully marked rotated. If false, BOTH keys are temporarily live — surface to user." },
                    "balance_cents": { "type": "integer" },
                    "scopes": { "type": "string" }
                  }
                }
              }
            }
          },
          "400": {
            "description": "bad_request — caller is the master key (not rotatable via API)"
          },
          "401": {
            "description": "Missing, invalid, revoked, or auto-revoked API key. Auto-revocation happens after the failure budget is exhausted — 3 consecutive generate failures with no successful render between. This is a buyer protection: it stops a broken integration from cycling your user's balance. Successful renders reset the counter to zero. If your user's key was working an hour ago and now returns 401, the most likely cause is the failure budget triggering auto-revoke. Manual re-activation requires emailing support@igdek.com."
          }
        }
      }
    },
    "/api/v1/keys/revoke": {
      "post": {
        "operationId": "revokeKey",
        "summary": "Revoke the caller's API key (kill switch)",
        "description": "Marks the caller's api_key as revoked. All subsequent calls return 401 invalid_api_key. Balance is NOT auto-refunded — refunds for revoked keys go through support@igdek.com to prevent abuse (e.g., a bot revoking a freshly-paid key to claim a refund).\n\nUse case: leak suspected, kill switch. Cannot revoke the master key.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": { "type": "string", "description": "Optional audit note." }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Revoked",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["revoked", "revoked_at"],
                  "properties": {
                    "revoked": { "type": "boolean" },
                    "revoked_at": { "type": "string" },
                    "reason": { "type": "string" }
                  }
                }
              }
            }
          },
          "400": {
            "description": "bad_request — caller is the master key"
          },
          "401": {
            "description": "Missing, invalid, revoked, or auto-revoked API key. Auto-revocation happens after the failure budget is exhausted — 3 consecutive generate failures with no successful render between. This is a buyer protection: it stops a broken integration from cycling your user's balance. Successful renders reset the counter to zero. If your user's key was working an hour ago and now returns 401, the most likely cause is the failure budget triggering auto-revoke. Manual re-activation requires emailing support@igdek.com."
          }
        }
      }
    },
    "/api/v1/billing/checkout": {
      "post": {
        "operationId": "billingCheckout",
        "summary": "Create a Polar checkout for API credits (public; supports new-key + topup)",
        "description": "Creates a request for API credits and returns a request_id the caller polls until completion. The response shape depends on path.\n\n## Three paths\n\n**Full-pay new-key path (public, no auth).** Omit X-API-Key. Provide email + amount. We create a Polar checkout and return its URL. After the user pays, the order webhook provisions a fresh api_key (delivered via /api/v1/billing/status AND emailed as a backup). Response: `{checkout_url, request_id, status: 'pending', expires_at}`.\n\n**Trial-discount path (public, no auth, no X-API-Key).** Provide `discount_code='MCPTRIAL'` + email + amount. We do NOT create a Polar checkout up front — instead we email a click-to-claim verification link to the address. The Polar checkout is created when the user clicks the link (see /api/v1/billing/verify). This is a structural defense against attacks that exploit Polar's public-confirm endpoint to mark $0 trial checkouts as paid without owning the email. Response: `{request_id, status: 'awaiting_verification', verification_email_sent_to, verification_expires_at, expires_at, agent_guidance}` — NO checkout_url. The agent should tell the user to check their email and click 'Claim'.\n\n**Topup path (X-API-Key required).** Send a valid existing key in the X-API-Key (or Authorization: Bearer) header. The new charge credits that key's balance. Email is ignored. discount_code rejected on this path. Response same as full-pay.\n\n## Amount range\n\n$5 minimum (500 cents), $1000 maximum (100000 cents) per request. $4.99 = one generation job. Larger purchases must contact support.\n\n## OAuth note\n\nThis endpoint is also reached internally from /oauth/authorize on the OAuth path. OAuth-driven requests apply a tighter $200 ceiling at the /oauth/authorize layer due to chargeback risk; direct callers of /api/v1/billing/checkout retain the full $1000 ceiling. **discount_code is rejected on the OAuth path** — trial onboarding is direct-API only.\n\n## Idempotency\n\nNo client-supplied idempotency key. Each call is a new request_id. To avoid duplicate charges from retries, surface the returned checkout_url (or verification email instructions) to the user once and reuse for ~30 minutes.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["amount_cents"],
                "properties": {
                  "amount_cents": {
                    "type": "integer",
                    "minimum": 500,
                    "maximum": 100000,
                    "description": "Credit amount in cents. $5 floor, $1000 ceiling.",
                    "example": 500
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "description": "Required for new-key path. Where the api_key will be emailed as a backup. Ignored on topup."
                  },
                  "name": {
                    "type": "string",
                    "description": "Optional. Friendly label attached to the new key (e.g., \"Bob's collection agent\"). Ignored on topup."
                  },
                  "return_url": {
                    "type": "string",
                    "format": "uri",
                    "description": "DEPRECATED. The field is silently ignored — forwarding it to Polar would let a hostile agent phish post-payment. Polar's default confirmation page is used instead. Agents should poll /api/v1/billing/status for the api_key rather than relying on a redirect.",
                    "deprecated": true
                  },
                  "metadata": {
                    "type": "object",
                    "description": "Optional. Opaque key/value strings echoed back in /api/v1/billing/status for agent-loop correlation.",
                    "additionalProperties": { "type": "string" }
                  },
                  "discount_code": {
                    "type": "string",
                    "description": "Optional. Onboarding code — pass 'MCPTRIAL' on the first call to redeem a free $4.99 job. One per email, limited campaign. Unrecognized codes (typo, exhausted, already redeemed) return 400 bad_request — drop the field and retry to pay normally.",
                    "example": "MCPTRIAL"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Checkout created. Surface checkout_url to the user; poll /api/v1/billing/status with request_id every 5–10 seconds.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/BillingCheckoutResponse" }
              }
            }
          },
          "400": {
            "description": "Invalid amount, missing email on new-key path, malformed JSON, OR `discount_code` rejected for one of these reasons: (a) discount on topup path — discount campaigns are first-time-onboarding only; drop the field. (b) email domain on the embedded disposable-email blocklist — use a permanent address. (c) discount_code unrecognized (campaign ended, code mistyped, or already redeemed) — drop to pay full amount. Inspect the `error` text for which condition applied.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
            }
          },
          "401": {
            "description": "X-API-Key was set but doesn't match an active key (topup path failure). Possible causes: key revoked, key auto-revoked after the 3-failure budget was exhausted (buyer-protection circuit breaker), or wrong key.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
            }
          },
          "503": {
            "description": "Trial discount campaign is operator-paused (typically during a live abuse incident). Returned with `code=campaign_paused`. Drop the `discount_code` field and retry — full-price purchase still works. Don't retry the discount immediately; the operator will lift the pause when the incident is resolved.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
            }
          },
          "502": {
            "description": "Polar API unreachable. Retry.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
            }
          }
        }
      }
    },
    "/api/v1/billing/status": {
      "get": {
        "operationId": "billingStatus",
        "summary": "Poll a credit request (public; request_id is the credential)",
        "description": "Returns the current state of a billing request. The request_id IS the credential — no X-API-Key required. Implication: protect request_id like a key. Expired pending rows auto-transition on read.\n\n## Status meanings\n\n- **awaiting_verification** — trial-discount path only. User must click the link in their email before the Polar checkout is created. Includes `verification_email_sent_to` and `verification_expires_at`. Tell the user to check their email.\n- **pending** — Polar checkout is live; user hasn't paid yet. Includes `expires_at` (RFC3339); typically 30 minutes.\n- **completed** — paid. Includes api_key (only on new-key path), is_new_key flag, balance_cents, polar_order_id, and any metadata echoed back.\n- **expired** — verification link or Polar window timed out. Caller must restart with a fresh /api/v1/billing/checkout.\n- **failed** — webhook detected an ownership mismatch or other error and refunded the charge. Includes a human-readable reason.\n\n## Trial-flow state transitions\n\n`awaiting_verification` → `pending` (user clicked email) → `completed` (user paid at Polar). Each transition takes seconds at most once the user acts.\n\n## Polling cadence\n\nEvery 5–10 seconds is reasonable. Typical wait is 20–90 seconds depending on how fast the user clicks Pay at Polar; trial verification adds an extra email-click step that depends entirely on user latency.",
        "security": [],
        "parameters": [
          {
            "name": "request_id",
            "in": "query",
            "required": true,
            "schema": { "type": "string" },
            "description": "The request_id returned by /api/v1/billing/checkout."
          }
        ],
        "responses": {
          "200": {
            "description": "Current state of the billing request. Inspect status to decide whether to poll again or act on the result.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/BillingStatusResponse" }
              }
            }
          },
          "400": {
            "description": "Missing request_id query parameter.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
            }
          },
          "404": {
            "description": "No billing request with that ID. Either it never existed, or it expired and was cleaned up.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } }
            }
          }
        }
      }
    },
    "/api/v1/billing/verify": {
      "get": {
        "operationId": "billingVerify",
        "summary": "Trial verification click target (browser-only, returns HTML or redirects)",
        "description": "Click target of the trial-discount verification email. Public — the (request_id, token) tuple from the email URL is the credential. **This endpoint is intended for browsers, not agents.** It returns HTML on non-redirect outcomes and a 302 redirect to a Polar checkout on the happy path.\n\n## Why this exists\n\nThe trial-discount path on /api/v1/billing/checkout does NOT create a Polar checkout up front. Instead it stamps an `awaiting_verification` row and emails a click-to-claim link. This endpoint validates the click and creates the Polar checkout synchronously. Until a valid (request_id, token) tuple reaches this endpoint, no Polar checkout URL exists for the trial — so attacks that exploit Polar's public confirmation endpoint (e.g., POSTing to `/v1/checkouts/client/{client_secret}/confirm` to bypass payment for $0 trial orders) have no target.\n\n## Outcomes\n\n- **Happy path** — valid token within window: 302 redirect to a fresh Polar checkout URL. The user pays $0 (the discount is applied), the webhook fires, the api_key is emailed.\n- **Re-click after first verify** — same row in `pending` state: 302 redirect to the existing Polar checkout (idempotent).\n- **Expired token** — 410 Gone with HTML 'verification link expired'.\n- **Invalid token / unknown request_id** — 404 with HTML 'verification link not recognized'. We deliberately don't distinguish 'wrong token' from 'no such request' to avoid leaking row existence.\n- **Already completed** — 200 with HTML 'trial already claimed' (key was emailed).\n- **Campaign paused** — 503 with HTML 'campaign temporarily paused'.\n\n## Agents should not call this directly\n\nAgents poll /api/v1/billing/status. The user is the one who clicks the email; the verify endpoint exists for them. An agent that programmatically follows the link bypasses the gate's purpose.",
        "security": [],
        "parameters": [
          {
            "name": "request_id",
            "in": "query",
            "required": true,
            "schema": { "type": "string" },
            "description": "From the verification email URL."
          },
          {
            "name": "token",
            "in": "query",
            "required": true,
            "schema": { "type": "string" },
            "description": "From the verification email URL. Single-use; pairs with request_id to authorize the verify."
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to the Polar checkout URL. Browser follows automatically. Body is empty.",
            "headers": {
              "Location": {
                "schema": { "type": "string", "format": "uri" },
                "description": "Polar checkout URL — https://polar.sh/checkout/<polar_checkout_id>"
              }
            }
          },
          "200": {
            "description": "HTML page — trial already claimed. The api_key was emailed to the user.",
            "content": { "text/html": { "schema": { "type": "string" } } }
          },
          "400": {
            "description": "HTML page — request_id or token query parameter missing/malformed.",
            "content": { "text/html": { "schema": { "type": "string" } } }
          },
          "404": {
            "description": "HTML page — request_id not found OR token mismatch (responses are intentionally indistinguishable).",
            "content": { "text/html": { "schema": { "type": "string" } } }
          },
          "410": {
            "description": "HTML page — verification link expired or already-failed.",
            "content": { "text/html": { "schema": { "type": "string" } } }
          },
          "503": {
            "description": "HTML page — operator paused the trial campaign between request creation and click.",
            "content": { "text/html": { "schema": { "type": "string" } } }
          }
        }
      }
    },
    "/oauth/authorize": {
      "get": {
        "operationId": "oauthAuthorize",
        "summary": "OAuth 2.1 authorization endpoint (browser-facing HTML form)",
        "description": "Renders an HTML consent + amount form. Used by MCP clients that authenticate via OAuth (ChatGPT Pro Connectors). PKCE is mandatory; only S256 is supported.\n\nOn POST, the form creates a Polar checkout and 302s the user to it. The success_url points back to /oauth/callback, which then 302s the user to the original redirect_uri with a code.\n\nSee the discovery document at /.well-known/oauth-authorization-server for the full client metadata. The OAuth path applies a $200 ceiling (vs the direct API's $1000) due to chargeback risk on the most-anonymous surface.",
        "security": [],
        "parameters": [
          { "name": "response_type", "in": "query", "required": true, "schema": { "type": "string", "enum": ["code"] } },
          { "name": "client_id", "in": "query", "required": true, "schema": { "type": "string" } },
          { "name": "redirect_uri", "in": "query", "required": true, "schema": { "type": "string", "format": "uri" } },
          { "name": "code_challenge", "in": "query", "required": true, "schema": { "type": "string" }, "description": "PKCE base64url(SHA256(code_verifier))." },
          { "name": "code_challenge_method", "in": "query", "required": true, "schema": { "type": "string", "enum": ["S256"] } },
          { "name": "state", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Echoed back to redirect_uri unchanged." },
          { "name": "scope", "in": "query", "required": false, "schema": { "type": "string", "default": "lookup generate" } }
        ],
        "responses": {
          "200": { "description": "HTML form rendered." },
          "400": { "description": "Missing or invalid OAuth parameter (HTML error page)." }
        }
      }
    },
    "/oauth/callback": {
      "get": {
        "operationId": "oauthCallback",
        "summary": "Polar success_url target — redirects user back to the OAuth client",
        "description": "Internal endpoint. Polar redirects the paying user here with ?request_id=...; we look up the original OAuth context and 302 the user to the client's redirect_uri with code={request_id}&state=... The webhook may not have fired by the time the user lands here — that's fine, /oauth/token returns authorization_pending until it does.",
        "security": [],
        "parameters": [
          { "name": "request_id", "in": "query", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "302": { "description": "Redirect to client's redirect_uri with code + state." },
          "400": { "description": "Missing request_id." },
          "404": { "description": "No matching billing request (expired or never existed)." }
        }
      }
    },
    "/oauth/token": {
      "post": {
        "operationId": "oauthToken",
        "summary": "OAuth 2.1 token endpoint",
        "description": "Exchanges an authorization code (request_id) + PKCE verifier for an access_token. The access_token IS the api_key — durable, no refresh, no expiry from our side. Errors follow RFC 6749 §5.2 (JSON envelope with `error` + `error_description`).\n\nThe `authorization_pending` error is RFC 8628 §3.5 — we use it so polling clients have a clear signal to keep polling while the Polar webhook is still in flight.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": ["grant_type", "code", "redirect_uri", "code_verifier"],
                "properties": {
                  "grant_type": { "type": "string", "enum": ["authorization_code"] },
                  "code": { "type": "string", "description": "The request_id returned by /oauth/callback as `code`." },
                  "redirect_uri": { "type": "string", "format": "uri", "description": "Must match the redirect_uri sent to /oauth/authorize." },
                  "code_verifier": { "type": "string", "description": "PKCE verifier — plain string whose SHA256 base64url == the code_challenge sent to /oauth/authorize." },
                  "client_id": { "type": "string", "description": "Echoed for compatibility; not validated against client_secret (this is a public client + PKCE)." }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Access token issued. The access_token IS the api_key.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/OAuthTokenResponse" }
              }
            }
          },
          "400": {
            "description": "RFC 6749 §5.2 error envelope. Common error codes: invalid_request, invalid_grant, unsupported_grant_type, authorization_pending, expired_token, access_denied.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/OAuthErrorResponse" }
              }
            }
          }
        }
      }
    },
    "/.well-known/oauth-authorization-server": {
      "get": {
        "operationId": "oauthAuthServerMetadata",
        "summary": "RFC 8414 authorization-server metadata",
        "security": [],
        "responses": {
          "200": {
            "description": "Discovery document for OAuth clients.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          }
        }
      }
    },
    "/.well-known/oauth-protected-resource": {
      "get": {
        "operationId": "oauthProtectedResourceMetadata",
        "summary": "RFC 9728 protected-resource metadata",
        "security": [],
        "responses": {
          "200": {
            "description": "Resource metadata pointing at the authorization server.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ErrorResponse": {
        "type": "object",
        "description": "Standard error response. All errors follow this format.",
        "required": ["error", "code", "request_id"],
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable error message.",
            "example": "Insufficient balance"
          },
          "code": {
            "type": "string",
            "description": "Machine-readable error code. Use this for programmatic error handling.",
            "enum": [
              "bad_request",
              "invalid_api_key",
              "insufficient_balance",
              "rate_limited",
              "lookup_budget_exceeded",
              "lookup_ratio_exceeded",
              "lookup_aggregate_limit",
              "campaign_paused",
              "concurrent_limit",
              "incomplete_cert_data",
              "not_in_inventory",
              "file_too_large",
              "invalid_image",
              "psa_rate_limit",
              "upstream_error",
              "not_ready",
              "not_found",
              "method_not_allowed",
              "internal"
            ],
            "example": "insufficient_balance"
          },
          "request_id": {
            "type": "string",
            "description": "Unique request ID for support and debugging.",
            "example": "4bb63bf5-8701-44f0-8a62-7effa4d355a9"
          }
        }
      },
      "CertResult": {
        "type": "object",
        "required": ["found", "cert_number"],
        "description": "PSA cert lookup result. Always check found=true before using the data. Check has_images before passing to /generate/cert. When found=false, only cert_number and found are guaranteed — all other fields may be absent.",
        "properties": {
          "found": {
            "type": "boolean",
            "description": "true if the cert number exists in PSA's database. false means the number is invalid or not yet registered.",
            "example": true
          },
          "cert_number": { "type": "string", "example": "108864068" },
          "subject": {
            "type": "string",
            "description": "Card subject as PSA records it.",
            "example": "THE GIANT"
          },
          "year": {
            "type": "string",
            "description": "Card year. May be empty for some vintage cards.",
            "example": "1940"
          },
          "brand": {
            "type": "string",
            "description": "Card brand/manufacturer.",
            "example": "BARRATT & CO. LTD"
          },
          "card_number": {
            "type": "string",
            "description": "Card number within the set. May be empty.",
            "example": ""
          },
          "variety": {
            "type": "string",
            "description": "Card variety/parallel. May be empty.",
            "example": "CHARACTERS FROM FAIRY TALES"
          },
          "grade": {
            "type": "string",
            "description": "Full grade text.",
            "example": "NM-MT 8"
          },
          "grade_numeric": {
            "type": "string",
            "description": "Numeric grade only.",
            "example": "8"
          },
          "has_images": {
            "type": "boolean",
            "description": "true if PSA has front/back images for this cert. false for certs graded before ~2021. If false, use the upload path and provide your own card photos.",
            "example": true
          },
          "front_image_url": {
            "type": ["string", "null"],
            "format": "uri",
            "description": "URL to front card image. Populated when has_images=true and balance >= $9.98. Empty string or null when images are redacted or unavailable."
          },
          "back_image_url": {
            "type": ["string", "null"],
            "format": "uri",
            "description": "URL to back card image. Populated when has_images=true and balance >= $9.98. Empty string or null when images are redacted or unavailable."
          },
          "population": {
            "type": ["integer", "null"],
            "description": "Number of cards with this exact grade for this card. Null if population data unavailable.",
            "example": 1
          },
          "population_higher": {
            "type": ["integer", "null"],
            "description": "Number of cards graded higher than this one. Null if unavailable.",
            "example": 0
          },
          "images_redacted": {
            "type": "boolean",
            "description": "true when your API key balance is below $9.98. Card data is returned but image URLs are empty. Add credits to access images."
          },
          "images_redacted_reason": {
            "type": "string",
            "description": "Explains why images were redacted and what balance is needed.",
            "example": "Add credits to access card images. Minimum balance: $9.98."
          }
        }
      },
      "CardInput": {
        "type": "object",
        "description": "Card data for generation. You can pass the raw output from /lookup here — the field names match. At minimum, front_image_url must be a valid, accessible HTTPS URL.",
        "required": ["front_image_url"],
        "properties": {
          "cert_number": {
            "type": "string",
            "description": "PSA cert number. Displayed on the output if provided.",
            "example": "108864068"
          },
          "subject": {
            "type": "string",
            "description": "Card subject/player name. Displayed prominently on the hero frame.",
            "example": "THE GIANT"
          },
          "year": { "type": "string", "example": "1940" },
          "brand": { "type": "string", "example": "BARRATT & CO. LTD" },
          "grade": {
            "type": "string",
            "description": "Full grade text. Displayed on the hero frame.",
            "example": "NM-MT 8"
          },
          "card_number": { "type": "string" },
          "variety": { "type": "string", "example": "CHARACTERS FROM FAIRY TALES" },
          "front_image_url": {
            "type": "string",
            "format": "uri",
            "description": "Required. HTTPS URL to front card image. Must be publicly accessible. Max 10MB. JPEG, PNG, or WebP."
          },
          "back_image_url": {
            "type": "string",
            "format": "uri",
            "description": "HTTPS URL to back card image. If omitted, no back frame is generated for this card."
          },
          "population": {
            "type": "integer",
            "description": "Population count at this grade. Used for the population frame.",
            "example": 1
          },
          "population_higher": {
            "type": "integer",
            "description": "Count graded higher. Used for the population frame.",
            "example": 0
          },
          "price": {
            "type": "string",
            "description": "Asking price for this card. Rendered on the price frame. Include currency symbol.",
            "example": "$5,000"
          },
          "cta_type": {
            "type": "string",
            "enum": ["dm_purchase", "dm_info"],
            "description": "Call-to-action when no price is set. 'dm_purchase' renders 'DM to purchase', 'dm_info' renders 'DM for info'. Defaults to 'dm_purchase'. Ignored when price is provided."
          }
        }
      },
      "UploadCardInput": {
        "type": "object",
        "description": "Card data for the upload generation path. Provide your own card images and a headline. Use this when you don't have PSA cert data, or for any grading company (SGC, BGS, CGC, CSG), or raw/ungraded cards.",
        "required": ["front_image_url", "headline"],
        "properties": {
          "front_image_url": {
            "type": "string",
            "format": "uri",
            "description": "Required. HTTPS URL to front card image. Must be publicly accessible. Max 10MB. JPEG, PNG, or WebP."
          },
          "back_image_url": {
            "type": "string",
            "format": "uri",
            "description": "HTTPS URL to back card image. If omitted, no back frame is generated for this card."
          },
          "headline": {
            "type": "string",
            "maxLength": 60,
            "description": "Text rendered on the hero frame. This is NOT a title — it's the exact text shown on the card. Example: '1986 Fleer Michael Jordan RC'"
          },
          "price": {
            "type": "string",
            "description": "Asking price. Rendered on the price frame. Include currency symbol. Examples: '$500', '€2,500'"
          },
          "cta_type": {
            "type": "string",
            "enum": ["dm_purchase", "dm_info"],
            "description": "Call-to-action when no price is set. 'dm_purchase' renders 'DM to purchase', 'dm_info' renders 'DM for info'. Defaults to 'dm_purchase'. Ignored when price is provided."
          }
        }
      },
      "JobCreated": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean",
            "description": "Always true on 202.",
            "example": true
          },
          "job_id": {
            "type": "string",
            "description": "UUID for this job. Use this to poll /api/v1/jobs/{id} and to get the download URL.",
            "example": "4bb63bf5-8701-44f0-8a62-7effa4d355a9"
          },
          "status": {
            "type": "string",
            "enum": ["pending"],
            "description": "Always 'pending' at creation time.",
            "example": "pending"
          },
          "request_id": {
            "type": "string",
            "description": "Unique request ID for support and debugging.",
            "example": "4bb63bf5-8701-44f0-8a62-7effa4d355a9"
          }
        }
      },
      "JobStatus": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "example": "4bb63bf5-8701-44f0-8a62-7effa4d355a9" },
          "status": {
            "type": "string",
            "enum": ["pending", "processing", "completed", "failed"],
            "description": "pending → processing → completed (or failed). Typical total time: 8-25 seconds.",
            "example": "completed"
          },
          "progress": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "description": "0.0 to 1.0. Only meaningful when status is 'processing'.",
            "example": 1.0
          },
          "message": {
            "type": "string",
            "description": "Human-readable step description.",
            "example": "Generation complete"
          },
          "error": {
            "type": "string",
            "description": "Error message. Only present when status is 'failed'. Balance was refunded automatically.",
            "example": "Failed to download card image"
          }
        }
      },
      "BillingCheckoutResponse": {
        "type": "object",
        "required": ["request_id", "status", "expires_at"],
        "description": "Response varies by path:\n\n- **Full-pay path** (no discount_code, OR topup with X-API-Key): `status='pending'`, `checkout_url` present — surface to user.\n- **Trial path** (discount_code='MCPTRIAL', no X-API-Key): `status='awaiting_verification'`, NO `checkout_url`, instead `verification_email_sent_to` + `verification_expires_at`. The user must click the link in their email; the Polar checkout is created at click time. Tell the user to check their email.",
        "properties": {
          "checkout_url": {
            "type": "string",
            "format": "uri",
            "description": "Polar checkout URL. Surface this to the user as a clickable link. Present on full-pay/topup paths only — absent on the trial-discount path (Polar checkout is deferred until email verification). Single-use; expires alongside the billing request (~30 minutes)."
          },
          "request_id": {
            "type": "string",
            "description": "Pass to /api/v1/billing/status to poll for completion. Single-use credential — protect like an API key.",
            "example": "f6b3c1a2-..."
          },
          "status": {
            "type": "string",
            "enum": ["pending", "awaiting_verification"],
            "description": "'pending' on the full-pay path (Polar checkout is live, user must pay). 'awaiting_verification' on the trial-discount path (verification email sent, user must click)."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "RFC3339. Overall row expiry — after this point the request is GC'd. ~30 minutes from creation."
          },
          "verification_email_sent_to": {
            "type": "string",
            "format": "email",
            "description": "Trial path only. The email address where the verification link was delivered. Surface to the user so they know which inbox to check."
          },
          "verification_expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Trial path only. RFC3339. The verification link expires at this time (typically 30 minutes from creation). After this, the user must restart with a fresh /api/v1/billing/checkout."
          },
          "agent_guidance": {
            "type": "string",
            "description": "Trial path only. Plain-English instruction for the agent to relay to the user (e.g., 'tell the user to check their email and click the Claim button')."
          }
        }
      },
      "BillingStatusResponse": {
        "type": "object",
        "required": ["status"],
        "description": "Response shape varies by status — see field-level descriptions for which combinations are valid. The trial-discount path goes through awaiting_verification → pending → completed; the full-pay path skips awaiting_verification.",
        "properties": {
          "status": {
            "type": "string",
            "enum": ["awaiting_verification", "pending", "completed", "expired", "failed"]
          },
          "verification_email_sent_to": {
            "type": "string",
            "format": "email",
            "description": "Only on awaiting_verification. The email where the trial verification link was sent. Surface to the user so they know which inbox to check."
          },
          "verification_expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Only on awaiting_verification. RFC3339. Verification link expiry — after this point, the request auto-transitions to expired and the user must restart."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "On awaiting_verification or pending. RFC3339. Overall row TTL."
          },
          "api_key": {
            "type": "string",
            "description": "Only on completed + new-key path. Pass as X-API-Key (or Authorization: Bearer) on subsequent IGdek API calls."
          },
          "balance_cents": {
            "type": "integer",
            "description": "Only on completed. Current balance in cents after this credit was applied."
          },
          "is_new_key": {
            "type": "boolean",
            "description": "Only on completed. true if api_key is fresh; false if this was a topup of an existing key."
          },
          "polar_order_id": {
            "type": "string",
            "description": "Only on completed. Polar's order ID for receipt-keeping."
          },
          "metadata": {
            "type": "object",
            "description": "Only on completed. Echoed from /api/v1/billing/checkout for agent-loop correlation.",
            "additionalProperties": { "type": "string" }
          },
          "reason": {
            "type": "string",
            "description": "Only on failed. Human-readable failure reason (e.g., ownership mismatch + auto-refund)."
          },
          "volume_signal": {
            "type": "object",
            "description": "Optional, present on any status when this billing request was created during a high-volume burst from your network (per-IP + per-email-domain sliding window). Surfaces a sales-touch hint with a next-step URL — do not treat as an error or a price quote. Safe to ignore for one-off purchases.",
            "required": ["detected", "reason", "next_step_url", "contact_email"],
            "properties": {
              "detected": { "type": "boolean", "enum": [true] },
              "reason": { "type": "string", "description": "Plain-English explanation of why the hint fired." },
              "score": { "type": "integer", "description": "Count of recent /api/v1/billing/checkout events from your bucket within window_hours." },
              "window_hours": { "type": "integer", "example": 1 },
              "estimated_monthly_jobs": { "type": "integer", "description": "Back-of-envelope extrapolation of score × hours-per-month at observed cadence. Approximate; intended as a conversation starter, not a contract." },
              "next_step_url": { "type": "string", "format": "uri", "example": "https://igdek.com/volume-pricing" },
              "contact_email": { "type": "string", "format": "email", "example": "sales@igdek.com" },
              "context_request_id": { "type": "string", "description": "Same value as the parent response's request_id; convenience field so the hint is self-contained for forwarding to a human." },
              "include_this_when_contacting": { "type": "string", "description": "Reference token for sales correspondence. Currently identical to context_request_id." },
              "pricing_via_inquiry_only": { "type": "boolean", "enum": [true], "description": "Pricing is sales-conversation only — there is no programmatic quote in this payload. Do not infer pricing from any other field." },
              "agent_guidance": { "type": "string", "description": "Surface contact_email and next_step_url to the human user who deployed you. Do not attempt to negotiate volume pricing programmatically." }
            }
          }
        }
      },
      "OAuthTokenResponse": {
        "type": "object",
        "required": ["access_token", "token_type"],
        "properties": {
          "access_token": {
            "type": "string",
            "description": "The api_key. Send as X-API-Key OR Authorization: Bearer on subsequent IGdek calls. Durable — no expiry from our side."
          },
          "token_type": {
            "type": "string",
            "enum": ["Bearer"]
          },
          "scope": {
            "type": "string",
            "example": "lookup generate"
          },
          "expires_in": {
            "type": "integer",
            "description": "Seconds. Advertised as 1 year for client compatibility, but the underlying api_key is durable.",
            "example": 31536000
          }
        }
      },
      "OAuthErrorResponse": {
        "type": "object",
        "required": ["error"],
        "description": "RFC 6749 §5.2 error envelope.",
        "properties": {
          "error": {
            "type": "string",
            "description": "Machine-readable error code.",
            "enum": [
              "invalid_request",
              "invalid_grant",
              "unsupported_grant_type",
              "authorization_pending",
              "expired_token",
              "access_denied",
              "server_error"
            ]
          },
          "error_description": {
            "type": "string",
            "description": "Human-readable explanation."
          }
        }
      }
    },
    "securitySchemes": {
      "apiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Send `X-API-Key: <key>` on every IGdek-API request."
      },
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Send `Authorization: Bearer <key>` — same key as apiKey, alternate header. MCP clients tend to prefer this shape."
      },
      "oauth2": {
        "type": "oauth2",
        "description": "OAuth 2.1 + PKCE. The access_token returned by /oauth/token IS the api_key — durable, used as a Bearer token on subsequent calls.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://api.igdek.com/oauth/authorize",
            "tokenUrl": "https://api.igdek.com/oauth/token",
            "scopes": {
              "lookup": "Look up PSA cert numbers (no balance deduction).",
              "generate": "Generate mobile-optimized 9:16 content jobs ($4.99 per job)."
            }
          }
        }
      }
    }
  },
  "security": [
    { "apiKey": [] },
    { "bearerAuth": [] },
    { "oauth2": ["lookup", "generate"] }
  ]
}
