{"openapi":"3.1.0","info":{"title":"MemeStack API","version":"1.0.0","description":"Visual search engine for memes, infographics, charts, and screenshots. Find images by meaning, the text inside them, or visual similarity. AI-captioned, OCR-indexed, Lightning-ranked. All read endpoints are public — no authentication required.","contact":{"name":"MemeStack","url":"https://memestack.ai"},"termsOfService":"https://memestack.ai/terms"},"servers":[{"url":"https://api.memestack.ai"}],"externalDocs":{"description":"Full API documentation and developer guide","url":"https://memestack.ai/developers"},"paths":{"/v1/agent/generate":{"post":{"summary":"Generate an AI meme image (paid, agent rail)","description":"Generate an image from a text prompt via Grok Imagine. PAID endpoint (agent payment rails — no account needed; the payment is the auth). Attach proof via PAYMENT-SIGNATURE or X-PAYMENT header (base64 JSON x402 payload, USDC on Base) or Authorization: L402 <macaroon>:<preimage> (Lightning sats). Without proof the response is 402 with a dual-rail offer. Rate limited 30/min/IP. Returns 404 while payments are disabled. Prices: 60 sats standard / 150 sats quality (~USD equivalent in USDC on the x402 rail). Long-polls up to ~90s; on 202, retry with the SAME payment proof to resume — one payment delivers at most one image.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["prompt"],"properties":{"prompt":{"type":"string","maxLength":2000},"mode":{"type":"string","enum":["standard","quality"],"default":"standard"},"aspect_ratio":{"type":"string","enum":["1:1","3:4","16:9"],"default":"1:1"}}}}}},"responses":{"200":{"description":"Image generated and delivered.","content":{"application/json":{"schema":{"type":"object","properties":{"image_id":{"type":"string","format":"uuid"},"url":{"type":"string"},"image_url":{"type":"string"},"thumbnail_url":{"type":"string"},"caption":{"type":"string"},"tags":{"type":"array","items":{"type":"object"}}}}}}},"202":{"description":"Still generating at the poll budget — claim stays open; retry with the SAME payment proof to collect."},"400":{"description":"Invalid body or malformed payment header (INVALID_PAYMENT_HEADER)."},"402":{"description":"Payment required. Body is the x402 v2 PaymentRequired envelope; the same envelope is base64-encoded in the PAYMENT-REQUIRED header, and WWW-Authenticate carries the L402 challenge (macaroon + bolt11 invoice). Pay on either rail and retry with proof.","headers":{"PAYMENT-REQUIRED":{"schema":{"type":"string"},"description":"base64(JSON) x402 v2 PaymentRequired envelope."},"WWW-Authenticate":{"schema":{"type":"string"},"description":"L402 macaroon=\"...\", invoice=\"<bolt11>\" — pay the invoice, retry with Authorization: L402 <macaroon>:<preimage>."}}},"410":{"description":"The generated image was removed; payment recorded for reconciliation (terminal)."},"422":{"description":"Rejected by content moderation (nsfw/gore/spam) — payment retained per policy (POLICY_REJECTED)."},"429":{"description":"Rate limit exceeded (30/min/IP)."},"502":{"description":"Generation failed before output (GENERATION_FAILED) — claim stays open, retry with the same proof (prompt change allowed)."},"503":{"description":"Payment rails temporarily unavailable (PAYMENT_UNAVAILABLE)."}}}},"/v1/agent/reverse-search":{"post":{"summary":"Reverse image search (paid, agent rail)","description":"Phash-based reverse image search over the approved catalog. PAID endpoint (agent payment rails — no account needed; the payment is the auth). Attach proof via PAYMENT-SIGNATURE or X-PAYMENT header (base64 JSON x402 payload, USDC on Base) or Authorization: L402 <macaroon>:<preimage> (Lightning sats). Without proof the response is 402 with a dual-rail offer. Rate limited 30/min/IP. Returns 404 while payments are disabled. Price: 21 sats per call. Same SSRF hardening and input rules as /v1/images/reverse-search.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["image_url"],"properties":{"image_url":{"type":"string","description":"HTTPS URL or data:image/...;base64,... URL of the query image."}}}}}},"responses":{"200":{"description":"Visually similar approved images (nearest first) + input metadata."},"400":{"description":"Invalid URL/body or malformed payment header."},"402":{"description":"Payment required. Body is the x402 v2 PaymentRequired envelope; the same envelope is base64-encoded in the PAYMENT-REQUIRED header, and WWW-Authenticate carries the L402 challenge (macaroon + bolt11 invoice). Pay on either rail and retry with proof.","headers":{"PAYMENT-REQUIRED":{"schema":{"type":"string"},"description":"base64(JSON) x402 v2 PaymentRequired envelope."},"WWW-Authenticate":{"schema":{"type":"string"},"description":"L402 macaroon=\"...\", invoice=\"<bolt11>\" — pay the invoice, retry with Authorization: L402 <macaroon>:<preimage>."}}},"429":{"description":"Rate limit exceeded (30/min/IP)."},"503":{"description":"Payment rails temporarily unavailable."}}}},"/v1/agent/submit":{"post":{"summary":"Submit an image to the moderation review queue (paid, agent rail)","description":"Submit an image by URL into the standard moderation pipeline (forceReview: true) — the payment IS the sybil-resistance mechanism, no account needed. Attach proof via PAYMENT-SIGNATURE or X-PAYMENT header (base64 JSON x402 payload, USDC on Base) or Authorization: L402 <macaroon>:<preimage> (Lightning sats). Without proof the response is 402 with a dual-rail offer, unless the 50/day/IP submission-offer cap is reached first (429). Rate limited 30/min/IP. Returns 404 while payments or agent submissions are disabled. Price: 100 sats (~USD equivalent in USDC on the x402 rail). NO REFUND if moderation rejects the submission (duplicate/nsfw/gore/spam/low_quality/invalid image) — the fee is retained as the anti-spam mechanism; only a failure before any submission row exists (bad URL, fetch error, oversize, non-image bytes) keeps the claim open for a corrected retry. Accepted images land in needs_review pending human approval, and join the public gallery under the MCP Agents account if approved. Long-polls up to ~90s; on 202, retry with the SAME payment proof to resume — up to 10 delivery attempts per payment, never charged twice.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["image_url"],"properties":{"image_url":{"type":"string","description":"HTTPS URL or data:image/(jpeg|png|webp);base64,... URL. Max 8MB."},"caption":{"type":"string","maxLength":200,"description":"Optional caption hint for the AI tagger. Admin-visible only, never published verbatim."},"tags":{"type":"array","items":{"type":"string","maxLength":40},"maxItems":10,"description":"Optional tag hints. Admin-visible only, never published verbatim."},"attribution":{"type":"string","maxLength":200,"description":"Optional credit line for the original creator/source. Shown to moderators only, never published."}}}}}},"responses":{"200":{"description":"Submission accepted — pending human review.","content":{"application/json":{"schema":{"type":"object","properties":{"image_id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["pending_review"]},"message":{"type":"string"},"page_url":{"type":"string"}}}}}},"202":{"description":"Still processing at the poll budget — claim stays open; retry with the SAME payment proof to resume."},"400":{"description":"Invalid body (INVALID_ARGS), malformed payment header (INVALID_PAYMENT_HEADER), or an image fetch/validation failure (FETCH_FAILED / NOT_AN_IMAGE / SSRF_BLOCKED) — all keep the claim open, retry with a corrected image_url and the SAME payment proof."},"402":{"description":"Payment required. Body is the x402 v2 PaymentRequired envelope; the same envelope is base64-encoded in the PAYMENT-REQUIRED header, and WWW-Authenticate carries the L402 challenge (macaroon + bolt11 invoice). Pay on either rail and retry with proof. Also returned when the 10-attempt delivery budget is exhausted (ATTEMPTS_EXHAUSTED) — a new payment is then required.","headers":{"PAYMENT-REQUIRED":{"schema":{"type":"string"},"description":"base64(JSON) x402 v2 PaymentRequired envelope."},"WWW-Authenticate":{"schema":{"type":"string"},"description":"L402 macaroon=\"...\", invoice=\"<bolt11>\" — pay the invoice, retry with Authorization: L402 <macaroon>:<preimage>."}}},"410":{"description":"The submitted image was removed; payment recorded for reconciliation (terminal)."},"413":{"description":"Fetched image exceeds the 8MB cap (TOO_LARGE) — claim stays open, retry with a smaller image."},"422":{"description":"Rejected by moderation (SUBMISSION_REJECTED: duplicate/nsfw/gore/spam/low_quality/invalid_image) — payment retained per policy. duplicate_of (image_id + page_url) is included when the match is an approved, publicly viewable image."},"429":{"description":"Daily submission-offer cap reached (SUBMIT_CAP_REACHED, 50/day/IP) or rate limit exceeded (30/min/IP)."},"502":{"description":"Could not enqueue the submission job (ENQUEUE_FAILED) — claim stays open, retry with the same proof."},"503":{"description":"Payment rails temporarily unavailable (PAYMENT_UNAVAILABLE)."}}}},"/auth/me":{"get":{"summary":"Get the authenticated user's profile, tier, and upload quota","description":"Returns the full profile for the calling user — tier name, daily upload limit, max batch file cap, uploads used today, and next-tier progression info. Used by the frontend upload page to size the file picker and show quota state.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Authenticated user profile.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"pubkey":{"type":"string","nullable":true,"description":"Lightning public key. Null for email-only accounts."},"email":{"type":"string","format":"email","nullable":true},"auth_method":{"type":"string","enum":["lightning","email"]},"has_pubkey":{"type":"boolean"},"display_name":{"type":"string","nullable":true},"role":{"type":"string","enum":["user","admin","system","creator"]},"balance_sats":{"type":"integer"},"approved_image_count":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"tier":{"type":"string","enum":["Pleb","Memer","Stacker","Memelord","Admin"],"description":"Computed upload tier based on account age and approved image count."},"daily_limit":{"type":"integer","nullable":true,"description":"Maximum uploads per day for this tier. Null means unlimited (Admin)."},"max_batch_files":{"type":"integer","description":"Tier-scaled maximum files per upload batch. Frontend uses this to size the upload-page picker cap."},"uploads_today":{"type":"integer"},"uploads_remaining":{"type":"integer","nullable":true,"description":"Uploads remaining today. Null means unlimited (Admin)."},"next_tier":{"type":"object","nullable":true,"description":"Next tier progression info. Null when the caller is already at the top tier.","properties":{"name":{"type":"string"},"requirement":{"type":"string"}}},"status":{"type":"string","enum":["active","restricted","frozen","banned"],"description":"Account moderation status."},"status_reason":{"type":"string","nullable":true},"status_until":{"type":"string","format":"date-time","nullable":true},"active_strike_count":{"type":"integer"},"token":{"type":"string","description":"Present only when the sliding-session refresh re-minted the JWT on this request (presented token >24h old, revocation/status gates passed) AND the request authenticated via Authorization: Bearer. Cookie-authenticated sessions rotate via Set-Cookie alone and never receive this field. Bearer clients should replace their stored token with it."}}},"example":{"id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","pubkey":"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","email":null,"auth_method":"lightning","has_pubkey":true,"display_name":"SatoshiUploads","role":"user","balance_sats":1000,"approved_image_count":35,"created_at":"2026-01-10T09:00:00Z","tier":"Memer","daily_limit":150,"max_batch_files":150,"uploads_today":3,"uploads_remaining":147,"next_tier":{"name":"Stacker","requirement":"50 approved images or 7 days"},"status":"active","status_reason":null,"status_until":null,"active_strike_count":0}}}},"401":{"description":"Authentication required."},"404":{"description":"User record not found."}}}},"/health":{"get":{"summary":"Health check","responses":{"200":{"description":"OK"}}}},"/v1/images":{"get":{"summary":"List approved images (simple listing — use /v1/images/search for filtered, sorted, paginated browsing)","parameters":[{"name":"status","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Paginated image list","content":{"application/json":{"schema":{"type":"object","properties":{"images":{"type":"array","items":{"$ref":"#/components/schemas/ImageRecord"}},"count":{"type":"integer"},"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"}}}}}}}}},"/v1/images/search":{"get":{"summary":"Search images — unified semantic + keyword","description":"Unified search combining semantic similarity (vector embeddings) with keyword matching. No mode parameter — every query runs both. Use `tags` for multi-tag AND filtering; `tag` is still accepted for single-tag backward compatibility.","parameters":[{"name":"q","in":"query","schema":{"type":"string"},"description":"Search query (optional — omit to browse without a query)"},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"maximum":100}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}},{"name":"tags","in":"query","schema":{"type":"string"},"description":"Comma-separated tag slugs for AND filtering (e.g. 'bitcoin,charts')"},{"name":"tag","in":"query","schema":{"type":"string"},"description":"Single tag slug filter — superseded by `tags` but still accepted for backward compatibility"},{"name":"uploader_id","in":"query","schema":{"type":"string"}},{"name":"sort_by","in":"query","schema":{"type":"string","enum":["created_at","zap_total_sats","save_count","trending"],"default":"created_at"},"description":"Field to sort by"},{"name":"sort_order","in":"query","schema":{"type":"string","enum":["asc","desc"],"default":"desc"},"description":"Sort direction"},{"name":"in_text_only","in":"query","schema":{"type":"boolean","default":false},"description":"Search ONLY the OCR-extracted text inside images, ignoring captions, alt text, and tags. Requires q. Substring match, case-insensitive."}],"responses":{"200":{"description":"Search results","content":{"application/json":{"schema":{"type":"object","properties":{"images":{"type":"array","items":{"$ref":"#/components/schemas/ImageRecord"}},"count":{"type":"integer"},"total":{"type":"integer"}}}}}}}}},"/v1/images/{id}/meta":{"get":{"summary":"Get image metadata","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Image metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageRecord"}}}},"404":{"description":"Image not found"}}}},"/v1/images/{id}/canonical":{"get":{"summary":"Serve web-optimized image (max 4000px, original format)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Image bytes","content":{"image/*":{}}},"404":{"description":"Image not found"}}}},"/v1/images/{id}/thumbnail":{"get":{"summary":"Serve thumbnail (max 768px)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Thumbnail bytes","content":{"image/*":{}}},"404":{"description":"Image not found"}}}},"/v1/images/{id}/similar":{"get":{"summary":"Find perceptually similar images (phash hamming distance)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","schema":{"type":"integer","default":10,"maximum":50}}],"responses":{"200":{"description":"Similar images","content":{"application/json":{"schema":{"type":"object","properties":{"images":{"type":"array","items":{"$ref":"#/components/schemas/ImageRecord"}}}}}}}}}},"/v1/images/{id}/related":{"get":{"summary":"Find semantically related images (embedding cosine similarity)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","schema":{"type":"integer","default":10,"maximum":50}}],"responses":{"200":{"description":"Related images","content":{"application/json":{"schema":{"type":"object","properties":{"images":{"type":"array","items":{"$ref":"#/components/schemas/ImageRecord"}}}}}}}}}},"/v1/users/{pubkey}":{"get":{"summary":"Get user profile and stats","parameters":[{"name":"pubkey","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"User profile","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"pubkey":{"type":"string"},"display_name":{"type":"string"},"role":{"type":"string"},"has_avatar":{"type":"boolean"},"upload_count":{"type":"integer"},"total_sats_earned":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"tier":{"type":"string","enum":["Pleb","Memer","Stacker","Memelord","Admin"],"description":"Computed upload tier. Frontend renders this as a colored pill on the user profile page."}}}}}},"404":{"description":"User not found"}}}},"/v1/users/{pubkey}/images":{"get":{"summary":"Get user's images (paginated)","parameters":[{"name":"pubkey","in":"path","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"maximum":100}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}},{"name":"sort_by","in":"query","schema":{"type":"string","enum":["created_at","zap_total_sats","save_count","trending"],"default":"created_at"},"description":"Field to sort by"},{"name":"sort_order","in":"query","schema":{"type":"string","enum":["asc","desc"],"default":"desc"},"description":"Sort direction"}],"responses":{"200":{"description":"User's images","content":{"application/json":{"schema":{"type":"object","properties":{"images":{"type":"array","items":{"$ref":"#/components/schemas/ImageRecord"}},"total":{"type":"integer"}}}}}}}}},"/v1/tags/popular":{"get":{"summary":"Popular tags for autocomplete","description":"Returns top tags by all-time usage count. Intended for frontend session caching — call once on page load, filter client-side. Edge-cached for 5 minutes.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":500,"minimum":1,"maximum":1000},"description":"How many tags to return (1-1000, default 500). MCP popular_tags tool defaults to 20."}],"responses":{"200":{"description":"Tag list","content":{"application/json":{"schema":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"object","properties":{"tag":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","enum":["controlled","free"]},"usage_count":{"type":"integer"}}}}}}}}}}}},"/v1/tags/search":{"get":{"summary":"Tag autocomplete by prefix","description":"Returns existing controlled and free tags whose slug or display name matches the prefix. Used by frontend autocomplete and the MCP tag_autocomplete tool.","parameters":[{"name":"q","in":"query","required":true,"schema":{"type":"string","minLength":2},"description":"Search prefix — must be at least 2 characters."},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":50},"description":"Max suggestions (1-50, default 20)."}],"responses":{"200":{"description":"Matching tags","content":{"application/json":{"schema":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"object"}}}}}}},"400":{"description":"Query too short (< 2 characters)","content":{"application/json":{"schema":{"type":"object","properties":{"tags":{"type":"array"},"error":{"type":"string"}}}}}}}}},"/v1/users/{pubkey}/tags":{"get":{"summary":"Get user's tags aggregated from their images","parameters":[{"name":"pubkey","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Tags with usage counts","content":{"application/json":{"schema":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"count":{"type":"integer"}}}}}}}}}}}},"/v1/leaderboard/images":{"get":{"summary":"Top zapped images by time period","parameters":[{"name":"period","in":"query","schema":{"type":"string","enum":["day","week","month","all"],"default":"all"}},{"name":"limit","in":"query","schema":{"type":"integer","default":10,"maximum":50}}],"responses":{"200":{"description":"Leaderboard entries","content":{"application/json":{"schema":{"type":"object","properties":{"images":{"type":"array","items":{"$ref":"#/components/schemas/ImageRecord"}}}}}}}}}},"/v1/leaderboard/zappers":{"get":{"summary":"Top zappers by time period","parameters":[{"name":"period","in":"query","schema":{"type":"string","enum":["day","week","month","all"],"default":"all"}},{"name":"limit","in":"query","schema":{"type":"integer","default":10,"maximum":50}}],"responses":{"200":{"description":"Top zappers","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/v1/takedowns/intimate":{"post":{"summary":"Submit a takedown notice (TAKE IT DOWN Act)","description":"Public, unauthenticated takedown form for non-consensual intimate imagery (NCII) and AI-generated deepfake impersonation. Federal compliance under the TAKE IT DOWN Act (signed May 2025). Valid notices are processed within 48 hours. Rate limited to 5 requests/hour/IP. Reporter info (name + email) is used only to process and confirm the request — never published.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["reporter_name","reporter_email","relationship","sworn_statement","description","category"],"properties":{"image_id":{"type":"string","format":"uuid","description":"UUID of the offending image. One of image_id / image_url is required."},"image_url":{"type":"string","format":"uri","description":"Memestack-hosted URL pointing at the offending image (gallery, API, or R2 path). External URLs are rejected — we can only take down content we host. One of image_id / image_url is required."},"reporter_name":{"type":"string","maxLength":200,"description":"Reporter's full name."},"reporter_email":{"type":"string","format":"email","maxLength":320,"description":"Reporter's email — used only for processing and confirmation. Not published."},"relationship":{"type":"string","enum":["self","representative","guardian"],"description":"Reporter's relationship to the depicted person."},"sworn_statement":{"type":"boolean","description":"Must be true. Affirms under penalty of perjury that the information is accurate."},"description":{"type":"string","minLength":20,"maxLength":5000,"description":"Free-form description of the content and why it should be removed."},"category":{"type":"string","enum":["intimate_nonconsensual","deepfake_impersonation"],"description":"Category of harm — non-consensual intimate imagery or AI deepfake impersonation."}}},"example":{"image_url":"https://memestack.ai/gallery/12345678-1234-1234-1234-123456789012","reporter_name":"Jane Doe","reporter_email":"jane@example.com","relationship":"self","sworn_statement":true,"description":"This image depicts me without my consent and was uploaded without permission.","category":"intimate_nonconsensual"}}}},"responses":{"200":{"description":"Takedown notice received. Returns generic-success even when the image is not on this platform (no info-leak about hosted content). When the image is found and the request is valid, includes a `reference_id` for follow-up.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"reference_id":{"type":"string","format":"uuid","description":"Reports table primary key. Quote in any follow-up email to takedown@memestack.ai."},"message":{"type":"string"}}}}}},"400":{"description":"Validation error (malformed body, invalid email, sworn_statement not true, image_url not on this platform, etc.).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}},"429":{"description":"Rate limit exceeded (5/hour/IP). For urgent cases, email takedown@memestack.ai directly."}}}},"/v1/feedback":{"post":{"summary":"Submit feedback (bug report or feature request)","description":"Public endpoint for bug reports and feature requests. Anonymous is allowed; an optional email enables follow-up, and the authenticated user (if any) is attached server-side. Rate limited per IP/user: anonymous 2/min + 10/hour, authenticated 10/min + 30/hour. Submissions land in the operator's admin queue and trigger a notification email.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["type","message"],"properties":{"type":{"type":"string","enum":["bug","feature"],"description":"Whether this is a bug report or a feature request."},"message":{"type":"string","minLength":10,"maxLength":5000,"description":"Free-form description of the bug or idea."},"email":{"type":"string","format":"email","maxLength":320,"description":"Optional contact email for follow-up. Auto-filled in the UI when logged in."},"context":{"type":"object","description":"Optional page/device context to help reproduce bugs. page_url is canonicalized server-side to a same-origin relative path (or dropped).","properties":{"page_url":{"type":"string","description":"Current page path; canonicalized to a same-origin relative path or nulled."},"user_agent":{"type":"string","maxLength":512},"screen":{"type":"string","maxLength":32,"description":"Viewport size, e.g. \"1920x1080\"."}}}}},"example":{"type":"bug","message":"The zap button does nothing on the gallery detail page in Firefox.","email":"reporter@example.com","context":{"page_url":"/gallery/12345678-1234-1234-1234-123456789012","user_agent":"Mozilla/5.0 ...","screen":"1920x1080"}}}}},"responses":{"200":{"description":"Feedback received. Returns a reference_id (the feedback row primary key).","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"reference_id":{"type":"string","format":"uuid"}}}}}},"400":{"description":"Validation error (bad type, message too short/long, malformed email).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}},"429":{"description":"Rate limit exceeded."}}}},"/v1/images/init-upload":{"post":{"summary":"Initialize a multipart upload — returns image_id and R2 upload token","description":"Creates a pending image record and a pre-authorized R2 multipart upload session. Callers upload directly to R2 using the returned upload token, then call /v1/images/complete-upload to queue processing. Requires authentication. Rate-limited per tier (daily quota and concurrent-pending cap) plus request pacing at 120/min per user (300/min for admin/creator); paced 429s return code RATE_LIMITED with a Retry-After header.","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["mime_type","size"],"properties":{"mime_type":{"type":"string","enum":["image/jpeg","image/png","image/webp"],"description":"MIME type of the image to be uploaded."},"size":{"type":"integer","description":"File size in bytes (max 50 MB)."},"file_hash":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"SHA-256 hex digest of the file — used for pre-upload duplicate detection. Optional but strongly recommended."}}}}}},"responses":{"200":{"description":"Upload session created.","content":{"application/json":{"schema":{"type":"object","properties":{"image_id":{"type":"string","format":"uuid","description":"Pending image record ID. Pass to /v1/images/complete-upload after the R2 upload finishes."},"upload_url":{"type":"string","description":"R2 multipart upload ID."},"r2_key":{"type":"string","description":"R2 object key for the upload."},"expires_in":{"type":"integer","description":"Seconds until the upload session expires (900 = 15 minutes)."}}}}}},"400":{"description":"Missing or invalid fields, unsupported MIME type, or file too large."},"401":{"description":"Authentication required."},"403":{"description":"Account banned or frozen — uploads disallowed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"frozen_until":{"type":"string","format":"date-time","description":"Only present when the account is frozen (not banned). ISO timestamp at which the freeze expires; the user transitions to 'restricted' afterward."}},"required":["error"]},"examples":{"banned":{"summary":"Banned account","value":{"error":"Your account has been banned. You cannot upload images."}},"frozen":{"summary":"Frozen account","value":{"error":"Your account is temporarily frozen. You cannot upload images.","frozen_until":"2026-06-04T12:00:00Z"}}}}}},"409":{"description":"Duplicate file — a record with this SHA-256 hash already exists.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["DUPLICATE_FILE"]},"similar_image_id":{"type":"string","format":"uuid"}}}}}},"429":{"description":"Upload limit reached — daily quota, concurrent-pending cap, or request-rate pacing. Inspect `code` to distinguish. RATE_LIMITED 429s (120 requests/min per user; 300/min for admin/creator) carry a Retry-After header — wait that many seconds and retry the same request. Quota 429s (DAILY_LIMIT_REACHED / PENDING_LIMIT_REACHED) are terminal until the quota resets or pending uploads drain.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["DAILY_LIMIT_REACHED","PENDING_LIMIT_REACHED","RATE_LIMITED"],"description":"Discriminator for the kind of 429 — daily quota vs concurrent-pending cap vs per-minute request pacing (retryable; honor Retry-After)."},"daily_limit":{"type":"integer","description":"Tier daily upload quota (only present when code === 'DAILY_LIMIT_REACHED')."},"uploads_today":{"type":"integer","description":"Uploads initiated today (only present when code === 'DAILY_LIMIT_REACHED')."},"pending_limit":{"type":"integer","description":"Maximum concurrent pending uploads for this tier (only present when code === 'PENDING_LIMIT_REACHED')."},"pending_count":{"type":"integer","description":"Current pending uploads (only present when code === 'PENDING_LIMIT_REACHED')."},"tier":{"type":"string","enum":["Pleb","Memer","Stacker","Memelord","Admin"],"description":"Caller's current upload tier."},"next_tier":{"type":"object","nullable":true,"description":"Next tier info (null for Memelord/Admin). Only present when code === 'DAILY_LIMIT_REACHED'.","properties":{"name":{"type":"string"},"requirement":{"type":"string"}}}}},"examples":{"daily_limit":{"summary":"Daily quota exhausted","value":{"error":"Daily upload limit reached","code":"DAILY_LIMIT_REACHED","daily_limit":50,"uploads_today":50,"tier":"Pleb","next_tier":{"name":"Memer","requirement":"30 approved images"}}},"pending_limit":{"summary":"Too many pending uploads","value":{"error":"Too many pending uploads (max 50). Please wait for current uploads to complete.","code":"PENDING_LIMIT_REACHED","pending_limit":50,"pending_count":50,"tier":"Pleb"}}}}}}}}},"/v1/images/reverse-search":{"post":{"summary":"Reverse image search by phash","description":"Find approved images visually similar to an arbitrary input image. Accepts an HTTPS URL or a `data:image/(jpeg|png|webp);base64,...` URL. SSRF-hardened (private IP / cloud metadata / DNS rebind blocked). Rate limited 10/min/IP via KV. Maximum 8 MB input.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","description":"HTTPS URL or data: URL of the image to search by."},"limit":{"type":"integer","default":10,"minimum":1,"maximum":20}}}}}},"responses":{"200":{"description":"Visually similar images, sorted by Hamming distance (nearest first).","content":{"application/json":{"schema":{"type":"object","properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/ImageRecord"}},"count":{"type":"integer"}}}}}},"400":{"description":"Invalid URL, blocked host (private/loopback/link-local), or non-image MIME type."},"429":{"description":"Rate limit exceeded (10/min/IP)."}}}},"/v1/collections/mine":{"get":{"summary":"List the caller's own collections (picker list)","description":"Own collections ordered by most-recently-touched, for the \"Add to collection\" picker. Pass contains_image_id to get a per-row `contains` boolean indicating whether that image is already a member. Rate limited 60/min.","security":[{"BearerAuth":[]}],"parameters":[{"name":"contains_image_id","in":"query","schema":{"type":"string","format":"uuid"},"description":"When present, each row gets a `contains` boolean for this image."}],"responses":{"200":{"description":"Own collections.","content":{"application/json":{"schema":{"type":"object","properties":{"collections":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"is_public":{"type":"boolean"},"updated_at":{"type":"string","format":"date-time"},"member_count":{"type":"integer","description":"Approved-member count."},"thumb_image_id":{"type":"string","format":"uuid","nullable":true,"description":"cover_image_id, or the first approved member if unset."},"contains":{"type":"boolean","description":"Only present when contains_image_id was passed."}}}}}}}}},"401":{"description":"Authentication required."}}}},"/v1/collections/{id}":{"get":{"summary":"Get a collection and one page of its member images","description":"Private collections are visible only to the owner or an admin — everyone else gets 404 (never 403, to avoid leaking existence). Banned curator also 404s publicly.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","schema":{"type":"integer","default":40,"maximum":100}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}}],"responses":{"200":{"description":"Collection detail with a page of approved member images.","content":{"application/json":{"schema":{"type":"object","properties":{"collection":{"$ref":"#/components/schemas/Collection"},"images":{"type":"array","items":{"$ref":"#/components/schemas/ImageRecord"}},"member_count":{"type":"integer"},"has_more":{"type":"boolean"}}}}}},"404":{"description":"Collection not found, private, or curator is banned."}}},"patch":{"summary":"Update a collection","description":"Owner or admin only. Accepts any subset of the editable fields (last-write-wins — single-owner surface, no CAS needed). `cover_image_id` must reference an approved member of the collection.","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":60,"description":"HTML-stripped and trimmed."},"description":{"type":"string","maxLength":500,"nullable":true},"is_public":{"type":"boolean"},"cover_image_id":{"type":"string","format":"uuid","nullable":true,"description":"Must be an approved member of this collection."}}}}}},"responses":{"200":{"description":"Updated collection.","content":{"application/json":{"schema":{"type":"object","properties":{"collection":{"$ref":"#/components/schemas/Collection"}}}}}},"400":{"description":"Validation error, empty body, or cover_image_id is not an approved member."},"403":{"description":"Not the owner or admin (collection is public)."},"404":{"description":"Collection not found, or private and caller is not owner/admin."}}},"delete":{"summary":"Delete a collection","description":"Owner or admin only. Hard delete — membership rows cascade; member images are unaffected.","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"403":{"description":"Not the owner or admin (collection is public)."},"404":{"description":"Collection not found, or private and caller is not owner/admin."}}}},"/v1/users/{identifier}/collections":{"get":{"summary":"List a user's collections (profile tab)","description":"Visitors see public collections only; the owner or an admin also sees private ones. `identifier` accepts a Lightning pubkey or a user UUID, mirroring GET /v1/users/{pubkey}.","parameters":[{"name":"identifier","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The user's collections.","content":{"application/json":{"schema":{"type":"object","properties":{"collections":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":"string","nullable":true},"is_public":{"type":"boolean"},"cover_image_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"member_count":{"type":"integer","description":"Approved-member count."},"preview_image_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Up to 4 approved member ids for a collage cover."}}}}}}}}},"404":{"description":"User not found."}}}},"/v1/collections":{"post":{"summary":"Create a collection","description":"Tier-capped via COLLECTION_LIMIT_PER_TIER (5 for Pleb up to 50 for Memelord, 5000 for Creator, unlimited for Admin). Requires a non-banned/frozen account.","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","maxLength":60,"description":"HTML-stripped and trimmed."},"description":{"type":"string","maxLength":500,"nullable":true},"is_public":{"type":"boolean","default":true}}},"example":{"name":"Best Bitcoin Charts","description":"Charts that explain the halving.","is_public":true}}}},"responses":{"201":{"description":"Collection created.","content":{"application/json":{"schema":{"type":"object","properties":{"collection":{"$ref":"#/components/schemas/Collection"}}}}}},"400":{"description":"Validation error (empty/too-long name or description)."},"401":{"description":"Authentication required."},"403":{"description":"Collection limit reached for the caller's tier.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["COLLECTION_LIMIT_REACHED"]}}}}}}}}},"/v1/collections/{id}/images":{"post":{"summary":"Add images to a collection","description":"Owner or admin only. 1–50 image ids per request; non-approved or already-member ids are silently skipped. Capacity-gated at 500 approved members (COLLECTION_MAX_IMAGES). Notifies affected uploaders when the collection is public.","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["image_ids"],"properties":{"image_ids":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"maxItems":50}}}}}},"responses":{"200":{"description":"Membership updated.","content":{"application/json":{"schema":{"type":"object","properties":{"added":{"type":"integer"},"skipped":{"type":"integer"},"member_count":{"type":"integer"}}}}}},"400":{"description":"image_ids missing, empty, not UUIDs, or over 50 entries."},"403":{"description":"Not the owner or admin (collection is public)."},"404":{"description":"Collection not found, or private and caller is not owner/admin."}}},"delete":{"summary":"Remove images from a collection","description":"Owner or admin only. 1–50 image ids per request. Clears the collection's cover_image_id if it was among the removed ids.","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["image_ids"],"properties":{"image_ids":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"maxItems":50}}}}}},"responses":{"200":{"description":"Membership updated.","content":{"application/json":{"schema":{"type":"object","properties":{"removed":{"type":"integer"},"member_count":{"type":"integer"}}}}}},"400":{"description":"image_ids missing, empty, not UUIDs, or over 50 entries."},"403":{"description":"Not the owner or admin (collection is public)."},"404":{"description":"Collection not found, or private and caller is not owner/admin."}}}},"/v1/collections/{id}/images/move":{"post":{"summary":"Move images from one collection to another","description":"Owner or admin of BOTH collections. 1–50 image ids. Capacity-gated on the target; an id that would exceed the target's 500-member cap stays in the source instead of being lost.","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Source collection id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["target_collection_id","image_ids"],"properties":{"target_collection_id":{"type":"string","format":"uuid"},"image_ids":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"maxItems":50}}}}}},"responses":{"200":{"description":"Images moved.","content":{"application/json":{"schema":{"type":"object","properties":{"moved":{"type":"integer"},"skipped":{"type":"integer"}}}}}},"400":{"description":"target_collection_id missing/invalid, equal to the source id, or image_ids malformed."},"403":{"description":"Not the owner or admin of the source or target collection (and it's public)."},"404":{"description":"Source or target collection not found, or private and caller is not owner/admin."}}}},"/v1/collections/{id}/images/{imageId}/move-to-top":{"post":{"summary":"Move an image to the front of a collection","description":"Owner or admin only. Sets the image's position ahead of the current first member.","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"imageId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Reordered.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"403":{"description":"Not the owner or admin (collection is public)."},"404":{"description":"Collection not found, or image is not a member of it."}}}},"/v1/imports":{"post":{"summary":"Start an archive import (zip or URL list)","description":"Creator/admin only (fresh DB role, re-checked server-side — not the JWT claim). Async bulk ingestion of up to 10,000 images per job; every image still flows through the normal per-image moderation pipeline (dedup, Gemini risk scan, determineImageStatus). Only one active import per creator at a time (409 IMPORT_ALREADY_ACTIVE). `kind: 'zip'` opens an R2 multipart upload session for the client to stream 90 MB parts into via PUT /v1/imports/{id}/parts/{n}, then POST .../complete-transfer. `kind: 'url_list'` submits the URLs synchronously (each syntactically https-only and SSRF-validated) and enqueues processing immediately.","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["kind"],"properties":{"kind":{"type":"string","enum":["zip","url_list"]},"size_bytes":{"type":"integer","description":"zip only. Declared archive size; re-validated against the actual assembled object at complete-transfer time. Max 10 GiB."},"urls":{"type":"array","items":{"type":"string","format":"uri"},"minItems":1,"maxItems":10000,"description":"url_list only. Each must be https and pass SSRF validation."},"user_context":{"type":"string","maxLength":200,"description":"Optional archive-wide tagging hint passed to the LLM tagger for every image in this import. Same 200-char semantics as the per-file upload hint."}}},"examples":{"zip":{"value":{"kind":"zip","size_bytes":524288000,"user_context":"2024 meme dump from Discord archive"}},"url_list":{"value":{"kind":"url_list","urls":["https://example.com/a.jpg","https://example.com/b.png"]}}}}}},"responses":{"201":{"description":"Import created. zip: multipart upload session opened. url_list: manifest written and processing already enqueued.","content":{"application/json":{"schema":{"type":"object","properties":{"import_id":{"type":"string","format":"uuid"},"upload_id":{"type":"string","description":"zip only — R2 multipart upload id, required on every part PUT."},"part_size":{"type":"integer","description":"zip only — fixed byte size every non-final part must equal exactly (IMPORT_PART_SIZE, 90 MB)."}}}}}},"400":{"description":"Validation error (bad kind, size_bytes over the limit, urls empty/over 10,000/non-https/SSRF-blocked)."},"403":{"description":"Caller is not a creator or admin (fresh DB role)."},"409":{"description":"The caller already has an active import.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["IMPORT_ALREADY_ACTIVE"]}}}}}}}},"get":{"summary":"List the caller's own archive imports","description":"Returns up to 20 of the caller's own imports, newest first. No role gate — a non-creator simply has an empty list.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"The caller's imports.","content":{"application/json":{"schema":{"type":"object","properties":{"imports":{"type":"array","items":{"$ref":"#/components/schemas/ArchiveImport"}}}}}}}}}},"/v1/imports/{id}/parts/{n}":{"put":{"summary":"Upload one part of a zip archive's multipart transfer","description":"Owner only. Non-final parts MUST be exactly IMPORT_PART_SIZE bytes (90 MB); send header `X-Last-Part: 1` on the final part, which may be up to and including that size. Requires a `Content-Length` header (chunked transfer encoding is not supported — the size check runs before any bytes are uploaded). The first accepted part transitions the import from awaiting_transfer to transferring.","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"n","in":"path","required":true,"schema":{"type":"integer","minimum":1},"description":"1-based part number."},{"name":"X-Last-Part","in":"header","required":false,"schema":{"type":"string","enum":["1"]},"description":"Set to 1 on the final part only."}],"requestBody":{"required":true,"content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"responses":{"200":{"description":"Part accepted.","content":{"application/json":{"schema":{"type":"object","properties":{"etag":{"type":"string"}}}}}},"400":{"description":"Missing/invalid Content-Length, or a non-final part is not exactly IMPORT_PART_SIZE bytes (or the final part exceeds it)."},"404":{"description":"Import not found, or caller is not the owner."},"409":{"description":"Import is not in awaiting_transfer or transferring status."}}}},"/v1/imports/{id}/complete-transfer":{"post":{"summary":"Finish a zip archive's multipart transfer and start processing","description":"Owner only. Assembles the uploaded parts, re-validates the assembled object's actual size against MAX_ARCHIVE_BYTES, then validates the ZIP central directory via ranged R2 reads (no full-file load) and checks the entry count is between 1 and MAX_IMPORT_ENTRIES. On success, transitions the import to queued and enqueues the first processing chunk. Any validation failure here also moves the import to a terminal `failed` status with `last_error` set.","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["parts"],"properties":{"parts":{"type":"array","minItems":1,"items":{"type":"object","required":["n","etag"],"properties":{"n":{"type":"integer","minimum":1},"etag":{"type":"string"}}}}}}}}},"responses":{"200":{"description":"Archive validated and processing started.","content":{"application/json":{"schema":{"type":"object","properties":{"total_entries":{"type":"integer"}}}}}},"400":{"description":"parts missing/empty/malformed, assembled object over MAX_ARCHIVE_BYTES, corrupt/unparseable archive (INVALID_ARCHIVE), or entry count outside [1, MAX_IMPORT_ENTRIES].","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["VALIDATION_ERROR","INVALID_ARCHIVE"]}}}}}},"404":{"description":"Import not found, or caller is not the owner."},"409":{"description":"Import is not in awaiting_transfer or transferring status."}}}},"/v1/imports/{id}/cancel":{"post":{"summary":"Cancel an in-progress import","description":"Owner only. Valid from any active status (awaiting_transfer, transferring, queued, running, draining). Already-processed images are left in place — only the remaining chunk chain stops. If the import was still mid-transfer, the R2 multipart upload session is aborted (best-effort).","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Cancelled.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"status":{"type":"string","enum":["cancelled"]}}}}}},"404":{"description":"Import not found, or caller is not the owner."},"409":{"description":"Import is already in a terminal state (completed, failed, cancelled, reverting, or reverted)."}}}},"/v1/imports/{id}":{"get":{"summary":"Get an archive import's status and live digest","description":"Owner or admin only (404 for anyone else — imports have no public visibility tier). The digest is computed live from `images.import_id` (per-status counts), not a denormalized counter, combined with the job row's own cursor/skip/error counters.","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Import detail + digest.","content":{"application/json":{"schema":{"type":"object","properties":{"import":{"$ref":"#/components/schemas/ArchiveImport"},"digest":{"type":"object","properties":{"by_status":{"type":"object","additionalProperties":{"type":"integer"},"description":"Per-image-status counts, e.g. {\"approved\": 120, \"needs_review\": 4}."},"skip_count":{"type":"integer"},"error_count":{"type":"integer"},"errors":{"type":"array","items":{"type":"object","properties":{"entry":{"type":"string"},"reason":{"type":"string"}}}},"cursor":{"type":"integer"},"total_entries":{"type":"integer","nullable":true}}}}}}}},"404":{"description":"Import not found, or caller is neither the owner nor an admin."}}}},"/v1/premium/products":{"get":{"summary":"List premium products","description":"Server-authoritative pricing for the three premium SKUs (Supporter / Ad-free / generation credits). `enabled:false` = purchases temporarily unavailable — the /premium page renders its coming-soon state from this flag; already-purchased benefits keep working regardless of this flag. Edge-cached for 60 seconds.","responses":{"200":{"description":"Premium product catalog.","content":{"application/json":{"schema":{"type":"object","properties":{"enabled":{"type":"boolean"},"products":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","enum":["supporter_30d","ad_free_30d","credits_10"]},"name":{"type":"string"},"description":{"type":"string"},"price_sats":{"type":"integer"},"kind":{"type":"string","enum":["supporter","ad_free","credits"]},"days":{"type":"integer","description":"Present on time-based products (supporter, ad_free)."},"credits":{"type":"integer","description":"Present on the credits product."}}}}}},"example":{"enabled":true,"products":[{"key":"supporter_30d","name":"Supporter","description":"Ad-free + Memelord-level quotas + profile badge for 30 days","price_sats":2000,"kind":"supporter","days":30},{"key":"ad_free_30d","name":"Ad-free","description":"No ads for 30 days","price_sats":1000,"kind":"ad_free","days":30},{"key":"credits_10","name":"10 generation credits","description":"10 standard AI generations (20% off)","price_sats":320,"kind":"credits","credits":10}]}}}}}}},"/v1/premium/purchase":{"post":{"summary":"Purchase a premium product from the custodial balance","description":"Debits balance_sats for one of the three premium SKUs in a single atomic statement (buyer debit + product effect + admin income, all-or-nothing). Non-refundable; purchases extend from max(now, current expiry) — a second purchase of a time-based product stacks rather than resetting. Requires a fresh, non-banned/frozen account. Rate limited 10/min/user. Returns 404 while PREMIUM_ENABLED is off — the switch stops new purchases only, already-purchased benefits keep working.","security":[{"BearerAuth":[]}],"parameters":[{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string","maxLength":64},"description":"Client-generated key (e.g. crypto.randomUUID()) preventing double-spend on retry. A replayed key returns the original response; missing header is a 400."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["product"],"properties":{"product":{"type":"string","enum":["supporter_30d","ad_free_30d","credits_10"]}}},"example":{"product":"supporter_30d"}}}},"responses":{"200":{"description":"Purchase complete. Returns the full post-purchase premium snapshot for an immediate client-side profile update.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","enum":[true]},"balance_sats":{"type":"integer"},"ad_free_until":{"type":"string","format":"date-time","nullable":true},"supporter_until":{"type":"string","format":"date-time","nullable":true},"generation_credits":{"type":"integer"}}},"example":{"success":true,"balance_sats":8000,"ad_free_until":null,"supporter_until":"2026-08-13T12:00:00Z","generation_credits":0}}}},"400":{"description":"Missing Idempotency-Key header (MISSING_IDEMPOTENCY_KEY), or product is not one of the known SKU keys (INVALID_PRODUCT).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["MISSING_IDEMPOTENCY_KEY","INVALID_PRODUCT"]}}}}}},"401":{"description":"Authentication required."},"402":{"description":"Insufficient balance — the debit gate failed and nothing was written.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["INSUFFICIENT_BALANCE"]},"balance_sats":{"type":"integer"},"required":{"type":"integer"}}}}}},"404":{"description":"Premium purchases are temporarily disabled (PREMIUM_ENABLED is off)."},"429":{"description":"Rate limit exceeded (10/min/user)."}}}},"/v1/memeverse/points":{"get":{"summary":"Memeverse semantic map points","description":"Columnar payload for the /memeverse WebGL map: 16 fixed category \"galaxies\" (geometry) plus every approved, placed image as a point (parallel arrays — ids as dashless UUID hex, integer x/y world coordinates, a category index, a 0-255 engagement glow byte, and a packed atlas sheet/tile reference or -1 when the point has no baked thumbnail yet). Edge-cached 10 minutes, matching the worker-trending placement cron cadence. No authentication required. Returns 404 while the feature is disabled (MEMEVERSE_ENABLED off); returns 200 with empty arrays before the first layout has ever been computed (frontend renders a \"forming\" state).","responses":{"200":{"description":"Map points payload.","content":{"application/json":{"schema":{"type":"object","properties":{"layout_version":{"type":"integer","description":"0 before the first full rebuild has ever run."},"world":{"type":"object","properties":{"size":{"type":"integer"}}},"generated_at":{"type":"string","format":"date-time"},"categories":{"type":"array","items":{"type":"object","properties":{"slug":{"type":"string"},"name":{"type":"string"},"x":{"type":"integer"},"y":{"type":"integer"},"r":{"type":"integer"},"count":{"type":"integer"}}}},"points":{"type":"object","description":"Parallel columnar arrays — all the same length, one entry per index across ids/x/y/cat/glow/atlas.","properties":{"ids":{"type":"array","items":{"type":"string"},"description":"Dashless 32-char UUID hex."},"x":{"type":"array","items":{"type":"integer"}},"y":{"type":"array","items":{"type":"integer"}},"cat":{"type":"array","items":{"type":"integer"},"description":"Index into the categories array."},"glow":{"type":"array","items":{"type":"integer"},"description":"0-255 engagement-derived brightness."},"atlas":{"type":"array","items":{"type":"integer"},"description":"Packed (sheet << 12 | tile) thumbnail atlas reference, or -1 when the point has no baked thumbnail yet."}}}}}}}},"404":{"description":"Memeverse is disabled (MEMEVERSE_ENABLED is not 'true')."}}}},"/v1/requests":{"post":{"summary":"Request an image you couldn't find","description":"Submit a meme/image request. Open to anonymous callers (rate-limited: 2/min burst, 5/day per IP; 20/day authenticated). Optional email gets a one-time notification when a matching image lands. Response includes the closest current matches.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["query_text"],"properties":{"query_text":{"type":"string","minLength":2,"maxLength":200},"details":{"type":"string","maxLength":1000},"email":{"type":"string","format":"email","description":"Anonymous callers only — notification channel"}}}}}},"responses":{"200":{"description":"Duplicate open request existed. Body: { id, deduped: true, similar: [] }"},"201":{"description":"Request created. Body: { id, similar: ImageRecord[] }"},"400":{"description":"Validation error"},"404":{"description":"Feature disabled"},"429":{"description":"Rate limited"}}}}},"components":{"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"JWT issued by /auth/callback (LNURL) or /auth/email/verify. Also accepted as the httpOnly cookie __Secure-bii-token for same-origin browser requests."}},"schemas":{"ImageRecord":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"uploader_id":{"type":"string"},"status":{"type":"string","enum":["pending","approved","needs_review","rejected"]},"filename":{"type":"string"},"mime_type":{"type":"string"},"size":{"type":"integer"},"width":{"type":"integer"},"height":{"type":"integer"},"thumb_width":{"type":"integer"},"thumb_height":{"type":"integer"},"caption":{"type":"string","nullable":true},"alt_text":{"type":"string","nullable":true},"tags":{"type":"array","items":{"type":"string"},"nullable":true},"free_tags":{"type":"array","items":{"type":"string"},"nullable":true},"text_in_image":{"type":"string","nullable":true},"zap_count":{"type":"integer"},"zap_total_sats":{"type":"integer"},"save_count":{"type":"integer"},"attribution_url":{"type":"string","nullable":true},"attribution_name":{"type":"string","nullable":true},"uploader_display_name":{"type":"string","nullable":true},"uploader_pubkey":{"type":"string","nullable":true},"uploader_website_url":{"type":"string","nullable":true,"description":"The uploader's own website URL — populated only for Verified Creator uploads (null otherwise)."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"example":{"id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","status":"approved","caption":"Bitcoin halving cycle chart","alt_text":"Line chart showing Bitcoin block reward reduction over time","tags":["bitcoin","charts"],"free_tags":["halving","supply schedule"],"text_in_image":"Block Reward: 50 → 25 → 12.5 BTC","width":1920,"height":1080,"zap_count":42,"zap_total_sats":1250,"save_count":15,"created_at":"2026-03-15T10:30:00Z"}},"Collection":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"curator_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":"string","nullable":true},"is_public":{"type":"boolean"},"cover_image_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"curator_display_name":{"type":"string","nullable":true},"curator_pubkey":{"type":"string","nullable":true},"curator_has_avatar":{"type":"boolean"},"member_count":{"type":"integer","description":"Approved-member count."}},"example":{"id":"b2c3d4e5-f6a7-8901-bcde-f12345678901","curator_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","name":"Best Bitcoin Charts","description":"Charts that explain the halving.","is_public":true,"cover_image_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","created_at":"2026-07-01T09:00:00Z","updated_at":"2026-07-07T12:00:00Z","curator_display_name":"SatoshiUploads","curator_pubkey":"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","curator_has_avatar":true,"member_count":12}},"ArchiveImport":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"creator_id":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["zip","url_list"]},"status":{"type":"string","enum":["awaiting_transfer","transferring","queued","running","draining","completed","failed","cancelled","reverting","reverted"]},"archive_size_bytes":{"type":"integer","nullable":true},"total_entries":{"type":"integer","nullable":true,"description":"Set once the transfer is validated (zip: central directory entry count; url_list: url count)."},"cursor":{"type":"integer","description":"Next entry index the consumer will process."},"error_count":{"type":"integer"},"skip_count":{"type":"integer","description":"Non-image entries, oversized entries, and dedup skips."},"errors":{"type":"array","items":{"type":"object","properties":{"entry":{"type":"string"},"reason":{"type":"string"}}},"description":"Capped at 200; overflow only increments error_count."},"last_error":{"type":"string","nullable":true},"user_context":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"started_at":{"type":"string","format":"date-time","nullable":true},"completed_at":{"type":"string","format":"date-time","nullable":true}},"example":{"id":"c3d4e5f6-a7b8-9012-cdef-123456789012","creator_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","kind":"zip","status":"running","archive_size_bytes":524288000,"total_entries":4200,"cursor":1150,"error_count":3,"skip_count":40,"errors":[{"entry":"corrupt.jpg","reason":"Unsupported or corrupted image format"}],"last_error":null,"user_context":"2024 meme dump from Discord archive","created_at":"2026-07-10T09:00:00Z","started_at":"2026-07-10T09:02:11Z","completed_at":null}}}}}