{"openapi":"3.1.0","info":{"title":"Gopigeon API","version":"0.1.0","description":"Anonymous one-curl form endpoints + durable agent-native queue rung.\n\nTwo pillars:\n  1. /new + /f/{form_id} — anonymous form endpoint creation, claim-on-first-submission flow.\n  2. /q/* — durable queue with per-queue Bearer auth, server-assigned monotonic seq, IETF Idempotency-Key dedupe.\n\nSee /llms.txt for a Quickstart and /llms-full.txt for a verbose reference. /healthz/self-test exercises the full happy path on a synthetic q_selftest_ queue.","license":{"name":"MIT"}},"servers":[{"url":"https://api.gopigeon.dev","description":"Production"},{"url":"http://localhost:8090","description":"Local development"}],"tags":[{"name":"forms","description":"Anonymous form endpoints (POST /new, POST /f/{form_id})."},{"name":"queue","description":"Durable agent-native queue (publish/pull/ack with cursor + Idempotency-Key)."},{"name":"discovery","description":"OpenAPI spec, llms.txt, healthz."}],"paths":{"/new":{"post":{"summary":"Create an anonymous form endpoint (zero-auth, agent-friendly)","tags":["forms"],"responses":{"201":{"description":"Endpoint created. Save the endpoint_url; the recipient gets a claim email after the first non-spam submission.","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Invalid recipient (missing, malformed, or placeholder address)"},"405":{"description":"Method not allowed (use POST)"},"413":{"description":"Request body too large (>4KB)"},"429":{"description":"Rate limit exceeded (per-IP creation cap)"}},"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded OR application/json":{"schema":{"type":"object"},"example":"recipient=me%2Btest%40gopigeon.dev"}},"description":"Required fields: recipient."}}},"/f/{form_id}":{"post":{"summary":"Submit data to a form endpoint","tags":["forms"],"responses":{"200":{"description":"Submission accepted"},"303":{"description":"Redirect to form.redirect_url after submission"},"400":{"description":"Invalid body or missing form ID"},"402":{"description":"Monthly submission quota exceeded — Free tier: 50 submissions/month per endpoint. Submission auto-enqueued for replay after upgrade to Pro (queued:true; will_replay_on_upgrade:true differentiates this owned-free path from the historical anonymous fallback). See /docs/curl#free-vs-pro.","content":{"application/json":{"schema":{"type":"object"},"example":"{\"error\":\"monthly submission quota exceeded\",\"plan\":\"free\",\"form_id\":\"f_...\",\"current\":50,\"limit\":50,\"upgrade_url\":\"https://gopigeon.dev/dashboard/billing\",\"queued\":true,\"will_replay_on_upgrade\":true,\"queue_id\":\"pu_...\",\"_help\":\"Monthly submission quota exceeded — Free tier: 50 submissions/month per endpoint.\"}"}}},"403":{"description":"CORS origin rejected, or form is inactive"},"404":{"description":"Form not found"},"410":{"description":"Endpoint expired (unclaimed TTL elapsed)"},"429":{"description":"Rate limit exceeded"}},"parameters":[{"name":"form_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"dry_run","in":"query","required":false,"schema":{"type":"string"},"description":"Optional dry-run flag (Phase 17 D-DOCS-04). When set to a truthy value (?dry_run=1, ?dry_run=true, ?dry_run=yes), the handler runs the full guard cascade — CORS, rate-limit, honeypot, quota read — and returns a routing preview only: sends nothing. No submission row inserted, no claim email sent, no funnel event emitted, no quota counter incremented, no queue publish. Response shape includes {dry_run: true, would_route_to, claim_status, validations_passed, would_have_counted}. Use to verify endpoint wiring before publishing a real form. Documented at https://gopigeon.dev/docs/curl.","example":"1"}],"requestBody":{"required":false,"content":{"application/x-www-form-urlencoded OR application/json":{"schema":{"type":"object"}}}}}},"/f/{form_id}/status":{"get":{"summary":"Probe a form endpoint's claim status (no auth, no side effects)","tags":["forms"],"responses":{"200":{"description":"Form exists. Returns {exists, claimed, active} only — no PII. claimed=true when a user has claimed the endpoint; active=false when is_active=0 or unclaimed TTL expired.","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Form not found (empty body — matches POST /f/{form_id} 404 surface; no new enumeration vector)"}},"parameters":[{"name":"form_id","in":"path","required":true,"schema":{"type":"string"}}]}},"/q/create":{"post":{"summary":"Create a durable queue (per-queue Bearer key returned ONCE)","tags":["queue"],"responses":{"201":{"description":"Queue created. Capture queue_api_key NOW — it is shown ONCE. Use as Authorization: Bearer <key> on every /q/{id}/* call.","content":{"application/json":{"schema":{"type":"object"},"example":"{\"queue_id\":\"q_abc123def456abcd\",\"queue_api_key\":\"qk_…64hex\",\"consumers\":[\"me\"],\"publish_url\":\"https://api.gopigeon.dev/q/q_abc.../publish\",\"pull_url\":\"https://api.gopigeon.dev/q/q_abc.../pull?cursor=0&consumer_id=me\",\"_help\":\"Queue created. Capture queue_api_key NOW — it is shown only once.\"}"}}},"400":{"description":"Missing or invalid consumers field, or invalid consumer_id shape"},"405":{"description":"Method not allowed (use POST)"},"413":{"description":"Request body too large (>4KB)"},"429":{"description":"Rate limit exceeded (per-IP creation cap)"}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object"},"example":"{\"consumers\":[\"me\"]}"}},"description":"Required fields: consumers. Optional fields: allowed_origins, ttl_days, depth_cap, max_msg_bytes."}}},"/q/{queue_id}/publish":{"post":{"summary":"Publish a message to a queue (Bearer auth + Idempotency-Key dedupe)","tags":["queue"],"responses":{"201":{"description":"Message accepted; envelope created with server-assigned monotonic seq","content":{"application/json":{"schema":{"type":"object"}}}},"402":{"description":"Monthly queue publish quota exceeded — Free tier: 100 messages/month per user across all queues. Envelope auto-enqueued for replay after upgrade to Pro (auto_enqueued:true; will_replay_on_upgrade differentiates owned-free vs anonymous fallback). See /docs/queues#free-vs-pro.","content":{"application/json":{"schema":{"type":"object"},"example":"{\"error\":\"monthly queue publish quota exceeded\",\"plan\":\"free\",\"queue_id\":\"q_...\",\"current\":100,\"limit\":100,\"upgrade_url\":\"https://gopigeon.dev/dashboard/billing\",\"auto_enqueued\":true,\"will_replay_on_upgrade\":true,\"pending_publish_id\":\"pp_...\",\"_help\":\"Monthly queue publish quota exceeded — Free tier: 100 messages/month per user across all queues.\",\"_links\":{}}"}}},"401":{"description":"Missing or invalid Bearer token"},"404":{"description":"Queue not found (or queue_id shape invalid)"},"405":{"description":"Method not allowed (use POST)"},"413":{"description":"Body exceeds queue.max_msg_bytes"},"422":{"description":"Idempotency-Key reused with different body (IETF draft-07); response includes code IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY"},"429":{"description":"Rate limit exceeded (per-IP publish cap)"},"503":{"description":"Queue store unavailable; retry with the same Idempotency-Key for safe dedupe"}},"parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string"},"example":"q_abc123def456abcd"},{"name":"Authorization","in":"header","required":true,"schema":{"type":"string"},"description":"Per-queue queue_api_key from POST /q/create","example":"Bearer qk_…"},{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string"},"description":"Per IETF draft-07. Same key+body within 24h returns the cached response (X-Idempotency-Replayed: true). Same key+different body returns 422."},{"name":"dry_run","in":"query","required":false,"schema":{"type":"string"},"description":"Optional dry-run flag (Phase 17 D-DOCS-04). When set to a truthy value (?dry_run=1, ?dry_run=true, ?dry_run=yes), the handler runs the full guard cascade — Bearer auth, body-size guard, read-only quota preview — and returns a routing preview only: sends nothing. No envelope, no queue publish, no quota increment, no idempotency-cache read or write. Response includes {dry_run: true, would_route_to, claim_status, would_have_counted: {quota_used, quota_remaining, period_resets_at}}. Use to verify queue topology before pushing real messages. Documented at https://gopigeon.dev/docs/curl.","example":"1"}],"requestBody":{"required":false,"content":{"application/json OR application/x-www-form-urlencoded":{"schema":{"type":"object"},"example":"{\"hello\":\"world\"}"}}},"security":[[{"queueKey":[]}]]}},"/q/{queue_id}/status":{"get":{"summary":"Probe a queue's claim status (no auth, no side effects)","tags":["queue"],"responses":{"200":{"description":"Queue exists. Returns {exists, claimed, active} only — no PII. claimed=true when a user owns the queue (owner_user_id IS NOT NULL); active follows is_active column.","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Queue not found (empty body — matches POST /q/{queue_id}/publish 404 surface)"}},"parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string"},"example":"q_abc123def456abcd"}]}},"/q/{queue_id}/pull":{"get":{"summary":"Pull messages from a queue (cursor NOT advanced — call /ack)","tags":["queue"],"responses":{"200":{"description":"Messages with seq > cursor (cursor not yet advanced — call /ack to commit)","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"consumer_id missing/invalid, cursor non-integer, or consumer not registered for this queue"},"401":{"description":"Missing or invalid Bearer token"},"404":{"description":"Queue not found (or queue_id shape invalid)"},"405":{"description":"Method not allowed (use GET)"}},"parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string"},"example":"q_abc123def456abcd"},{"name":"Authorization","in":"header","required":true,"schema":{"type":"string"},"description":"Per-queue queue_api_key from POST /q/create","example":"Bearer qk_…"},{"name":"cursor","in":"query","required":false,"schema":{"type":"integer"},"description":"Highest seq already acked. New consumers start at 0.","example":"0"},{"name":"consumer_id","in":"query","required":true,"schema":{"type":"string"},"description":"Must match a consumer registered at /q/create or via /q/{id}/consumers","example":"me"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer"},"description":"Default 10, hard cap 100 (T-01c-09 mitigation)","example":"10"}],"security":[[{"queueKey":[]}]]}},"/q/{queue_id}/ack":{"post":{"summary":"Idempotent UPSERT MAX cursor advance (sending lower cursor is no-op)","tags":["queue"],"responses":{"200":{"description":"Cursor advanced to MAX(stored, requested); idempotent UPSERT — sending a lower cursor is a no-op","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Missing cursor or consumer_id, or consumer not registered for this queue"},"401":{"description":"Missing or invalid Bearer token"},"404":{"description":"Queue not found"},"405":{"description":"Method not allowed (use POST)"}},"parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string"},"example":"q_abc123def456abcd"},{"name":"Authorization","in":"header","required":true,"schema":{"type":"string"},"description":"Per-queue queue_api_key from POST /q/create","example":"Bearer qk_…"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object"},"example":"{\"cursor\":42,\"consumer_id\":\"me\"}"}},"description":"Required fields: cursor, consumer_id."},"security":[[{"queueKey":[]}]]}},"/q/{queue_id}/consumers":{"post":{"summary":"Add or remove consumers (new consumers see ONLY future messages)","tags":["queue"],"responses":{"200":{"description":"Consumers updated. NOTE: new consumers see ONLY future messages (cursor-log is publish-time; late consumers start from current next_seq).","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Invalid consumer_id (must match ^[a-zA-Z][a-zA-Z0-9_-]{0,63}$)"},"401":{"description":"Missing or invalid Bearer token"},"404":{"description":"Queue not found"},"405":{"description":"Method not allowed (use POST)"}},"parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string"},"example":"q_abc123def456abcd"},{"name":"Authorization","in":"header","required":true,"schema":{"type":"string"},"example":"Bearer qk_…"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object"},"example":"{\"add\":[\"new-consumer\"]}"}},"description":"Optional fields: add, remove."},"security":[[{"queueKey":[]}]]},"get":{"summary":"List consumers registered on a queue","tags":["queue"],"responses":{"200":{"description":"Current consumer roster for this queue.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Missing or invalid Bearer token"},"404":{"description":"Queue not found"},"405":{"description":"Method not allowed (use GET)"}},"parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string"},"example":"q_abc123def456abcd"},{"name":"Authorization","in":"header","required":true,"schema":{"type":"string"},"description":"Per-queue queue_api_key from POST /q/create","example":"Bearer qk_…"}],"security":[[{"queueKey":[]}]]}},"/q/{queue_id}/origins":{"post":{"summary":"Additively mutate allowed_origins (add/remove individual origins)","tags":["queue"],"responses":{"200":{"description":"Allowed-origins list updated. CORS preflight will accept the new set on the next OPTIONS request.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Missing or invalid Bearer token"},"404":{"description":"Queue not found"},"405":{"description":"Method not allowed (use POST)"},"422":{"description":"Wildcard '*' refused, or origin failed validation (must be https://<host> or http://localhost(:port))"}},"parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string"},"example":"q_abc123def456abcd"},{"name":"Authorization","in":"header","required":true,"schema":{"type":"string"},"description":"Per-queue queue_api_key from POST /q/create","example":"Bearer qk_…"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object"},"example":"{\"add\":[\"https://example.com\"],\"remove\":[\"https://stale.example.com\"]}"}},"description":"Optional fields: add, remove."},"security":[[{"queueKey":[]}]]}},"/openapi.json":{"get":{"summary":"OpenAPI 3.1 spec (live-generated from *route-table* — never drifts)","tags":["discovery"],"responses":{"200":{"description":"Full OpenAPI 3.1.0 JSON spec for the public surface"}}}},"/llms.txt":{"get":{"summary":"Concise LLM-readable index (Quickstart + route list)","tags":["discovery"],"responses":{"200":{"description":"Plain-text index aimed at cold-LLM ingest","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/llms-full.txt":{"get":{"summary":"Verbose LLM-readable index with envelope, auth, errors, idempotency","tags":["discovery"],"responses":{"200":{"description":"Verbose plain-text reference. Includes /llms.txt content plus Envelope, Auth, Errors, Idempotency-Key sections.","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/healthz":{"get":{"summary":"Minimal health check (status + version)","tags":["discovery"],"responses":{"200":{"description":"{status: \"ok\", version: \"0.1.0\"}","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/healthz/self-test":{"get":{"summary":"Full happy-path + negative-path self-test on a synthetic q_selftest_ queue","tags":["discovery"],"responses":{"200":{"description":"All checks passed; synthetic queue created, exercised, and cleaned up","content":{"application/json":{"schema":{"type":"object"}}}},"503":{"description":"One or more checks failed (broker unreachable, route missing, etc); inspect .checks[].error for details"}}}}},"components":{"securitySchemes":{"queueKey":{"type":"http","scheme":"bearer","description":"Per-queue queue_api_key returned ONCE in the response from POST /q/create. Pass as Authorization: Bearer <queue_api_key>."}},"schemas":{"Envelope":{"type":"object","description":"Schema-versioned envelope wrapping every queue message (QUE-06). source enum: direct-publish | form-mirror.","required":["id","seq","queue_id","received_at","schema_version","schema_hash","source","payload"],"properties":{"id":{"type":"string","example":"qm_8e03978e40d5abcd","description":"Per-message nanoid, prefix qm_"},"seq":{"type":"integer","minimum":1,"description":"Server-assigned monotonic sequence (queues.next_seq)"},"queue_id":{"type":"string","example":"q_abc123def456abcd","description":"Owning queue id, prefix q_"},"received_at":{"type":"string","format":"date-time","example":"2026-04-29T15:20:01.000Z","description":"ISO 8601 UTC with millisecond precision"},"schema_version":{"type":"string","example":"1.0.0","description":"Envelope schema version (semver)"},"schema_hash":{"type":"string","example":"sha256:9f8b…","description":"Deterministic SHA-256 of the canonical payload SHAPE (field order independent, type aware)"},"source":{"type":"string","enum":["direct-publish","form-mirror"],"description":"Origin of this message"},"form_id":{"type":["string","null"],"description":"Populated only when source=form-mirror; the originating form id"},"payload":{"description":"Arbitrary user JSON (or form-encoded plist mapped to a JSON object)"}}}}}}