{"openapi":"3.1.0","info":{"title":"inboxy API","version":"0.3.0","summary":"Read + manage your inboxy.net inbox programmatically.","description":"REST surface over the inboxy alias inbox. Mint aliases, list/search/read\nmessages, fetch attachments, manage your API keys, and emit user-initiated\nmail under one of your aliases.\n\nMail-emitting paths (`POST /v1/messages/{id}/reply`, `POST /v1/mail/send`,\n`POST /v1/mail/invite`) are recipient-locked and alias-locked server-side:\nthe `From:` is always one of the caller's `@inboxy.net` aliases, the\nrecipient is validated against per-user caps and the abuse rules in\n`packages/reply`, and every external message carries the mandatory\ninboxy attribution footer. There is no arbitrary-`From:` send path.\n\nAll endpoints require a bearer API key created from the inboxy.net web UI\nat `/account/keys` (mint is gated by step-up and lives only on the web — there\nis no API-channel mint). Pass it as `Authorization: Bearer ik_…`.\n\nThe signed-token attachment download URLs (`/v1/attachments/{token}/download`,\n`/v1/messages/raw/{token}/download`) are public-by-token — they intentionally\nbypass bearer auth and are not listed here. Obtain them via the JSON\nendpoints below.","contact":{"name":"inboxy","url":"https://inboxy.net"},"license":{"name":"Proprietary"}},"servers":[{"url":"https://api.inboxy.net","description":"Production"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Account","description":"Caller identity."},{"name":"Billing","description":"Stripe checkout, billing portal, and plan changes."},{"name":"Addresses","description":"Mint and retire @inboxy.net aliases."},{"name":"Messages","description":"List, read, search, mark, delete."},{"name":"Mail","description":"Compose and send from your aliases."},{"name":"Outbox","description":"Logged outbound queue — what you or your agent sent."},{"name":"Attachments","description":"Signed download URLs."},{"name":"API keys","description":"Manage your own API keys."},{"name":"Feeds","description":"RSS / Atom feed subscriptions."},{"name":"Articles","description":"Read items from your feeds."},{"name":"Webhooks","description":"Outbound event notifications (paid plans)."},{"name":"Domains","description":"Verify a custom domain for a future sending feature (1 on free and standard, 10 on pro). Verification only — a verified domain cannot yet be used to send mail."},{"name":"Service","description":"Health, discovery."}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"ik_…","description":"Static API key. Three scopes — `read` (everything except mutations), `manage` (mutations other than sending) and `send` (`/v1/mail/send`, replies, invites). Mint at https://inboxy.net/account/keys (the web UI gates mint with step-up; there is no API-channel mint)."}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string","enum":["bad_request","unauthorized","forbidden","not_found","gone","rate_limited","internal","invalid_token","blocked"]},"message":{"type":"string"}}},"AccountSnapshot":{"type":"object","required":["user","subscription","quota","rate_limits","retention"],"properties":{"user":{"type":"object","required":["id","personal_email","plan","forward_rewrite_links"],"properties":{"id":{"type":"string","format":"uuid"},"personal_email":{"type":"string","format":"email"},"timezone":{"type":["string","null"]},"plan":{"type":"string","enum":["free","paid","pro"]},"forward_rewrite_links":{"type":"boolean"}}},"subscription":{"type":"object","required":["name","plan_id","since","renews_at","cancels_at"],"description":"Subscription card. `name` is the customer-facing plan name; `plan_id` is the immutable plan version the account is pinned to (limits only change when an operator moves the account to a new version). `renews_at` / `cancels_at` are currently always null — not yet wired to live Stripe subscription state.","properties":{"name":{"type":"string","example":"Free"},"plan_id":{"type":"string","example":"free_v1"},"since":{"type":"string","format":"date-time"},"renews_at":{"type":["string","null"],"format":"date-time"},"cancels_at":{"type":["string","null"],"format":"date-time"}}},"quota":{"type":"object","required":["addresses","agent_addresses","feeds","api_keys","kept_messages"],"properties":{"addresses":{"type":"object","required":["used","limit"],"properties":{"used":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":0}}},"agent_addresses":{"type":"object","description":"Separate allowance for `agent-…` and `<domain>-…` aliases.","required":["used","limit"],"properties":{"used":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":0}}},"feeds":{"type":"object","required":["used","limit"],"properties":{"used":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":0}}},"api_keys":{"type":"object","required":["active"],"properties":{"active":{"type":"integer","minimum":0}}},"kept_messages":{"type":"object","required":["used","limit"],"description":"Per-user retention pins. 10 hard cap for all plans v1.","properties":{"used":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":0}}},"mail_import":{"type":"object","required":["used","limit","period_resets_at"],"description":"Historic-mail imports (`POST /v1/mail/import`) this UTC calendar month against the plan's cap (`limit`). Duplicates are refunded.","properties":{"used":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":0},"period_resets_at":{"type":"string","format":"date-time"}}}}},"rate_limits":{"type":"object","required":["requests_per_min","forward_per_hour","reply_per_hour","feed_add_per_hour","feed_refresh_per_hour"],"description":"Caller-visible caps so agents can self-throttle.","properties":{"requests_per_min":{"type":"integer","minimum":1},"forward_per_hour":{"type":"integer","minimum":0},"reply_per_hour":{"type":"integer","minimum":0},"feed_add_per_hour":{"type":"integer","minimum":0},"feed_refresh_per_hour":{"type":"integer","minimum":0}}},"retention":{"type":"object","required":["message_body_days"],"properties":{"message_body_days":{"type":"integer","description":"Days a message body is kept before the daily retention sweep purges it. Set by the account's plan; a per-account override takes precedence. Messages pinned with mail_keep are exempt. Always a concrete window — never null."}}}}},"BillingCheckoutRequest":{"type":"object","required":["plan_id"],"properties":{"plan_id":{"type":"string","description":"A paid plan's immutable id, e.g. `standard_v1`. Free plans and unpurchasable plans (no `stripe_price_id` yet) are rejected.","example":"standard_v1"}}},"BillingChangePlanRequest":{"type":"object","required":["plan_id"],"properties":{"plan_id":{"type":"string","description":"The paid plan id to switch to. Must differ from the account's current plan.","example":"pro_v1"}}},"Address":{"type":"object","required":["id","local_part","full_address","status","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"local_part":{"type":"string"},"full_address":{"type":"string","format":"email"},"label":{"type":["string","null"]},"status":{"type":"string","enum":["active","retired"]},"kind":{"type":"string","enum":["standard","agent"],"description":"`agent` aliases draw from the separate agent allowance."},"created_at":{"type":"integer"},"retired_at":{"type":["integer","null"]},"total":{"type":"integer","minimum":0,"description":"Total messages ever received at this alias (list-view only)."},"unread":{"type":"integer","minimum":0,"description":"Unread messages currently on this alias (list-view only)."}}},"AddressCreate":{"type":"object","properties":{"label":{"type":"string","maxLength":64,"description":"Optional user-facing label."},"scheme":{"type":"string","enum":["adjective-noun","opaque"],"default":"adjective-noun","description":"Generation scheme for `standard` aliases. `adjective-noun` → e.g. brisk-otter-42; `opaque` → random."},"type":{"type":"string","enum":["standard","agent","domain"],"default":"standard","description":"`agent` → agent-xxxxxxxx; `domain` → yourdomain.com-xxxxxxxx (requires `domain_id`). Both are flagged `kind: agent`."},"domain_id":{"type":"string","format":"uuid","description":"A verified domain's id (from `GET /v1/domains`). Required (or `domain`) when `type` is `domain`."},"domain":{"type":"string","description":"Alternative to `domain_id`: the verified domain's hostname, e.g. `nike.com`."}}},"Webhook":{"type":"object","required":["id","url","enabled","consecutive_failures","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"url":{"type":"string","format":"uri"},"enabled":{"type":"boolean","description":"Auto-disabled after 5 consecutive delivery failures."},"consecutive_failures":{"type":"integer","minimum":0},"last_status":{"type":["string","null"]},"last_delivery_at":{"type":["integer","null"],"description":"Unix seconds."},"created_at":{"type":"integer"},"secret":{"type":"string","description":"Plaintext signing secret. Only present in the response to `POST /v1/webhooks` and `POST /v1/webhooks/{id}/rotate-secret` — never returned by `GET /v1/webhooks`."}}},"WebhookCreate":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","description":"HTTPS only; must not resolve to a private/reserved address."}}},"VerifiedDomain":{"type":"object","required":["id","domain","status","txt_ok","spf_state","spf_suggestion","dkim_cname_ok","last_checked_at","created_at","verified_at","dns_records"],"properties":{"id":{"type":"string","format":"uuid"},"domain":{"type":"string"},"status":{"type":"string","enum":["pending","verified","verification_lost"],"description":"`verified` after 3 consecutive passing checks; demoted to `verification_lost` after 2 consecutive failures once verified. Verification only — this status never grants a sending capability."},"txt_ok":{"type":"boolean","description":"Ownership TXT challenge record found and matching."},"spf_state":{"type":["string","null"],"enum":["ok","conflict","missing",null]},"spf_suggestion":{"type":["string","null"],"description":"Suggested SPF record text. Null iff `spf_state` is `ok`."},"dkim_cname_ok":{"type":"boolean","description":"DKIM selector CNAME found and matching."},"last_checked_at":{"type":["integer","null"],"description":"Unix seconds."},"created_at":{"type":"integer","description":"Unix seconds."},"verified_at":{"type":["integer","null"],"description":"Unix seconds of the transition into `verified`. A re-check that keeps the domain verified, or one that fails without demoting it, never clears this. It IS cleared back to null if the domain is demoted to `pending` (e.g. by rotating its verify token) — it re-marks the transition on the next fresh verification."},"dns_records":{"type":"object","required":["txt","spf","dkim"],"description":"The DNS records to publish at your registrar. `txt.value` is not a secret — it is meant to be published in public DNS, and (unlike a webhook's signing secret) is returned on every response, not just at creation.","properties":{"txt":{"type":"object","required":["host","value"],"properties":{"host":{"type":"string"},"value":{"type":"string"}}},"spf":{"type":["object","null"],"description":"Null once `spf_state` is `ok` — nothing left to change.","properties":{"host":{"type":"string"},"value":{"type":["string","null"]}}},"dkim":{"type":"object","required":["host","type","value"],"properties":{"host":{"type":"string"},"type":{"type":"string","enum":["CNAME"]},"value":{"type":"string"}}}}}}},"VerifiedDomainCreate":{"type":"object","required":["domain"],"properties":{"domain":{"type":"string","description":"Bare hostname, e.g. `mail.example.com`. No scheme, path, port, or IP literal."}}},"Classification":{"type":"string","enum":["otp","login_link","verify","receipt","notice","marketing","spam","other"]},"MessageMeta":{"type":"object","required":["id","from_email","received_at","read"],"properties":{"id":{"type":"string","format":"uuid"},"address_id":{"type":"string","format":"uuid"},"from_email":{"type":"string","format":"email"},"from_label":{"type":["string","null"]},"subject":{"type":["string","null"]},"received_at":{"type":"integer","description":"Unix seconds."},"classification":{"$ref":"#/components/schemas/Classification"},"urgency":{"type":["string","null"],"enum":["low","normal","high",null]},"summary":{"type":["string","null"]},"confidence":{"type":["number","null"]},"read":{"type":"boolean"},"pending_classification":{"type":"boolean"},"forwarded_at":{"type":["integer","null"]},"body_lost":{"type":"boolean"},"kept_at":{"type":["integer","null"],"description":"Unix seconds when the user pinned this message to bypass the retention sweep. Null = not kept."}}},"MessagesPage":{"type":"object","required":["messages"],"properties":{"messages":{"type":"array","items":{"$ref":"#/components/schemas/MessageMeta"}},"next_cursor":{"type":["string","null"],"description":"Pass to `?cursor=` for the next page."}}},"CountResult":{"type":"object","required":["total","unread"],"description":"Aggregate over a filtered set. `total` counts every matching row; `unread` is the subset that is still unread.","properties":{"total":{"type":"integer","minimum":0},"unread":{"type":"integer","minimum":0}}},"MarkAllResult":{"type":"object","required":["marked"],"description":"Result of a bulk mark-as-read. `marked` is the number of rows mutated by this call (already-read rows are skipped).","properties":{"marked":{"type":"integer","minimum":0}}},"MarkAllCombinedRequest":{"type":"object","description":"Optional filter for the combined zero-everything tool. Empty object zeroes all unread.","properties":{"before":{"type":"string","format":"date-time","description":"ISO-8601; only mark items received/published strictly before this instant."}}},"MarkAllCombinedResult":{"type":"object","required":["mail_marked","articles_marked"],"description":"Counts from /v1/read-all — rows actually changed in each domain.","properties":{"mail_marked":{"type":"integer","minimum":0},"articles_marked":{"type":"integer","minimum":0}}},"KeepResult":{"type":"object","required":["id","kept_at"],"description":"Result of a keep / unkeep call. `kept_at` is null after unkeep, unix-seconds after keep.","properties":{"id":{"type":"string","format":"uuid"},"kept_at":{"type":["integer","null"]}}},"OutboxMessage":{"type":"object","required":["id","kind","to_email","from_email","status","sent_at"],"description":"One sent reply. `status` reflects the send pipeline: queued (in-flight) → sent / rejected / suppressed. `body_text` + `body_html` are populated on /v1/outbox/{id} but elided from list rows.","properties":{"id":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["reply","forward","digest"]},"in_reply_to_message_id":{"type":["string","null"],"format":"uuid"},"to_email":{"type":"string","format":"email"},"from_email":{"type":"string","format":"email"},"subject":{"type":["string","null"]},"body_text":{"type":"string","description":"Plain-text body, only on /v1/outbox/{id}."},"body_html":{"type":["string","null"]},"message_id_rfc5322":{"type":["string","null"]},"status":{"type":"string","enum":["queued","sent","failed","rejected","suppressed"]},"status_reason":{"type":["string","null"]},"sent_at":{"type":"integer","description":"Unix seconds — when the row was enqueued."},"delivered_at":{"type":["integer","null"],"description":"Unix seconds — when the sender flipped to 'sent'."},"kept_at":{"type":["integer","null"]}}},"OutboxList":{"type":"object","required":["results"],"properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/OutboxMessage"}}}},"UnsubMethod":{"oneOf":[{"type":"object","required":["kind","url","source"],"properties":{"kind":{"type":"string","enum":["one_click_post"]},"url":{"type":"string","format":"uri"},"source":{"type":"string","enum":["header"]}}},{"type":"object","required":["kind","url","source"],"properties":{"kind":{"type":"string","enum":["http"]},"url":{"type":"string","format":"uri"},"source":{"type":"string","enum":["header","body"]},"text":{"type":["string","null"]}}},{"type":"object","required":["kind","url","to","subject","source"],"properties":{"kind":{"type":"string","enum":["mailto"]},"url":{"type":"string"},"to":{"type":"string","format":"email"},"subject":{"type":["string","null"]},"source":{"type":"string","enum":["header"]}}}]},"UnsubscribeInfo":{"type":"object","required":["id","from","subject","methods","recommended"],"description":"Unsubscribe methods parsed from a message. `recommended` is the first by preference: one_click_post (RFC 8058) → mailto → http link.","properties":{"id":{"type":"string","format":"uuid"},"from":{"type":["string","null"]},"subject":{"type":["string","null"]},"methods":{"type":"array","items":{"$ref":"#/components/schemas/UnsubMethod"}},"recommended":{"anyOf":[{"$ref":"#/components/schemas/UnsubMethod"},{"type":"null"}]}}},"MarkAllMessagesRequest":{"type":"object","description":"Optional filter scoping which messages are marked. Empty object marks every unread message read.","properties":{"address_id":{"type":"string","format":"uuid"},"classification":{"$ref":"#/components/schemas/Classification"},"before":{"type":"string","format":"date-time","description":"ISO-8601; only mark messages received strictly before this instant."}}},"MarkAllArticlesRequest":{"type":"object","description":"Optional filter scoping which articles are marked. Empty object marks every unread article read.","properties":{"feed_id":{"type":"string","format":"uuid","description":"Subscription id."},"before":{"type":"string","format":"date-time","description":"ISO-8601; only mark articles published strictly before this instant."}}},"ReplyRequest":{"type":"object","required":["body_text"],"description":"Reply body. The server appends a `--` separator + quoted-original block; callers cannot suppress it.","properties":{"body_text":{"type":"string","minLength":1,"maxLength":2000,"description":"Plain-text body. Capped at 2000 chars."},"body_html":{"type":"string","description":"Optional HTML body. Visible-text length capped server-side at 2000 chars."},"attachment_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":5,"description":"Ids returned by `POST /v1/outbox-attachments`. Each may be used at most once. Per-file cap 20 MB, total cap 20 MB."}}},"ReplyResult":{"type":"object","required":["id","replied","to","from","subject","idempotency_key"],"properties":{"id":{"type":"string","format":"uuid"},"replied":{"type":"boolean"},"to":{"type":"string","format":"email"},"from":{"type":"string","format":"email"},"subject":{"type":"string"},"idempotency_key":{"type":"string"},"attachments":{"type":"array","items":{"type":"object","properties":{"r2_key":{"type":"string"},"filename":{"type":["string","null"]},"content_type":{"type":"string"},"size_bytes":{"type":"integer"}}}}}},"AddressAttribution":{"type":"object","required":["id","address_id","site_origin","source","first_used_at","last_used_at"],"properties":{"id":{"type":"string","format":"uuid"},"address_id":{"type":"string","format":"uuid"},"site_origin":{"type":"string","description":"Scheme + host, e.g. `https://example.com`."},"site_label":{"type":["string","null"]},"source":{"type":"string","enum":["extension"]},"first_used_at":{"type":"integer","description":"Unix seconds."},"last_used_at":{"type":"integer","description":"Unix seconds."}}},"AddressAttributionRequest":{"type":"object","required":["site_origin"],"properties":{"site_origin":{"type":"string","description":"Scheme + host where the alias was used. Origin-validated server-side."},"site_label":{"type":"string","description":"Optional human label (e.g. site title); sanitised + truncated."}}},"MailSendRequest":{"type":"object","required":["from_local_part","to","subject"],"description":"Compose a new message from one of the caller's @inboxy.net aliases. The recipient is classified server-side: internal recipients (another `@inboxy.net` user) bypass external SMTP and land in the recipient's inbox; external recipients enqueue via the sender worker and carry the mandatory inboxy attribution footer.","properties":{"from_local_part":{"type":"string","description":"Local-part of the alias to send from. Must be one of the caller's active aliases. When the API key is address-scoped this is forced to the bound alias and `key_scope_mismatch` is returned on conflict."},"to":{"type":"string","description":"RFC 5322 recipient address. Mailbox-syntax validated server-side."},"subject":{"type":"string","maxLength":200,"description":"Trimmed and truncated at 200 chars."},"text":{"type":"string","maxLength":8000,"description":"Plain-text body. Required if `html` is absent."},"html":{"type":"string","maxLength":64000,"description":"Optional HTML body."},"in_reply_to":{"type":"string","description":"Optional `Message-Id` to thread against. Sanitized server-side before being written to headers."}}},"MailImportResult":{"type":"object","required":["message_id","message_id_rfc5322","duplicate","sent_by_user"],"properties":{"message_id":{"type":"string","format":"uuid","description":"The `messages.id` — the newly stored row on a 201, or the already-existing row on a duplicate, so it can be fetched either way."},"message_id_rfc5322":{"type":"string","description":"The message's `Message-ID:`, or `<inboxy-import-{sha256}@inboxy.net>` synthesised from the bytes when the header is absent. Dedup key per user."},"duplicate":{"type":"boolean","description":"`true` when this Message-ID was already stored for you; nothing was written and the quota was not spent."},"sent_by_user":{"type":"boolean","description":"`true` when the `From:` is your own sign-in address — mail you sent. Stored for context; `mail_reply` refuses it."}}},"MailSendResult":{"type":"object","required":["ok","delivery","from","to","subject","idempotency_key"],"properties":{"ok":{"type":"boolean","enum":[true]},"delivery":{"type":"string","enum":["internal","external"],"description":"`internal` = recipient is another inboxy user; delivered directly. `external` = enqueued onto `outbound-send` for the sender worker."},"message_id":{"type":"string","format":"uuid","description":"Present on internal delivery only — the recipient's new `messages.id`."},"from":{"type":"string","format":"email"},"to":{"type":"string","format":"email"},"subject":{"type":"string"},"idempotency_key":{"type":"string"}}},"MailInviteRequest":{"type":"object","required":["to"],"description":"Invite a friend to inboxy. The recipient receives a single magic-link email from `invites@mail.inboxy.net` — no further mail until they accept. Capped at 10 invites per rolling 30 days per inviter. Self-invites, invites to existing users, and invites to reserved local-parts are refused.","properties":{"to":{"type":"string","format":"email","description":"Invitee's personal email."},"personal_note":{"type":"string","maxLength":500,"description":"Optional ≤500-char note included in the invite body, labelled as written by the inviter. Links, web addresses and email addresses are refused (`note_has_link`)."}}},"MailInviteResult":{"type":"object","required":["invite_id","expires_at","invites_remaining"],"properties":{"invite_id":{"type":"string","description":"Opaque id; surfaces in `audit_log`."},"expires_at":{"type":"integer","description":"Unix seconds when the invite token expires (30 days from issue)."},"invites_remaining":{"type":"integer","description":"Remaining invites in the caller's rolling 30-day window."}}},"OutboxAttachmentUploadResult":{"type":"object","required":["id","size_bytes","content_type","expires_at"],"properties":{"id":{"type":"string","format":"uuid"},"size_bytes":{"type":"integer"},"content_type":{"type":"string"},"filename":{"type":["string","null"]},"expires_at":{"type":"integer","description":"Unix seconds after which the upload may be purged if unsent."}}},"Attachment":{"type":"object","required":["id","content_type","size_bytes"],"properties":{"id":{"type":"string","format":"uuid"},"filename":{"type":["string","null"]},"content_type":{"type":["string","null"]},"size_bytes":{"type":"integer"}}},"ImageRef":{"type":"object","required":["kind","proxy_url"],"properties":{"kind":{"type":"string","enum":["external","inline"]},"proxy_url":{"type":"string","format":"uri","description":"Short-lived l.inboxy.net/img/{token} URL. Original third-party URLs never leave the worker."},"alt":{"type":["string","null"]},"width":{"type":["string","null"]},"height":{"type":["string","null"]},"attachment_id":{"type":"string","description":"Inline-only."}}},"MessageDetail":{"allOf":[{"$ref":"#/components/schemas/MessageMeta"},{"type":"object","properties":{"envelope_from":{"type":["string","null"]},"text_body":{"type":["string","null"]},"headers":{"type":["object","null"],"additionalProperties":true},"attachments":{"type":"array","items":{"$ref":"#/components/schemas/Attachment"}},"images":{"type":"array","items":{"$ref":"#/components/schemas/ImageRef"}}}}]},"SignedUrl":{"type":"object","required":["url","expires_at"],"properties":{"url":{"type":"string","format":"uri"},"expires_at":{"type":"integer","description":"Unix seconds."},"filename":{"type":["string","null"]},"content_type":{"type":["string","null"]},"size_bytes":{"type":"integer"}}},"SearchRequest":{"type":"object","required":["query"],"properties":{"query":{"type":"string","minLength":1,"maxLength":1000},"limit":{"type":"integer","minimum":1,"maximum":50,"default":20},"filters":{"type":"object","properties":{"address_id":{"type":"string","format":"uuid"},"classification":{"$ref":"#/components/schemas/Classification"},"from_domain":{"type":"string"},"since":{"type":"string","format":"date-time"}}}}},"SearchResult":{"allOf":[{"$ref":"#/components/schemas/MessageMeta"},{"type":"object","required":["score"],"properties":{"score":{"type":"number","description":"Cosine similarity, 0..1."}}}]},"ApiKey":{"type":"object","required":["id","scope","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"label":{"type":["string","null"]},"scope":{"type":"string","example":"read,manage"},"created_at":{"type":"integer"},"last_used_at":{"type":["integer","null"]},"last_used_country":{"type":["string","null"]},"retired_at":{"type":["integer","null"]}}},"Feed":{"type":"object","required":["id","url","status","subscribed_at","unread"],"properties":{"id":{"type":"string","format":"uuid","description":"Per-user subscription id. Use this everywhere — the shared feed_sources id is never exposed."},"label":{"type":["string","null"]},"url":{"type":"string","format":"uri"},"title":{"type":["string","null"]},"description":{"type":["string","null"]},"homepage_link":{"type":["string","null"]},"language":{"type":["string","null"]},"status":{"type":"string","enum":["active","error","inactive"]},"last_polled_at":{"type":["integer","null"],"description":"Unix seconds."},"last_error":{"type":["string","null"]},"subscribed_at":{"type":"integer"},"total":{"type":"integer","minimum":0,"description":"Total articles in this feed (across all subscribers)."},"unread":{"type":"integer","description":"Your unread article count."}}},"FeedCreate":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","maxLength":2048},"label":{"type":"string","maxLength":64}}},"FeedPatch":{"type":"object","required":["label"],"properties":{"label":{"type":["string","null"],"maxLength":64}}},"ArticleMeta":{"type":"object","required":["id","feed_id","read","fetched_at"],"properties":{"id":{"type":"string","format":"uuid"},"feed_id":{"type":"string","format":"uuid","description":"The subscription id this article surfaces through."},"title":{"type":["string","null"]},"link":{"type":["string","null"],"format":"uri"},"author":{"type":["string","null"]},"published_at":{"type":["integer","null"]},"fetched_at":{"type":"integer"},"read":{"type":"boolean"}}},"ArticlesPage":{"type":"object","required":["articles"],"properties":{"articles":{"type":"array","items":{"$ref":"#/components/schemas/ArticleMeta"}},"next_cursor":{"type":["string","null"]}}},"ArticleDetail":{"type":"object","required":["id","feed_source_id","read","fetched_at"],"properties":{"id":{"type":"string","format":"uuid"},"feed_source_id":{"type":"string","format":"uuid"},"title":{"type":["string","null"]},"link":{"type":["string","null"],"format":"uri"},"author":{"type":["string","null"]},"published_at":{"type":["integer","null"]},"fetched_at":{"type":"integer"},"read":{"type":"boolean"},"content_html":{"type":["string","null"],"description":"Sanitised HTML with every <a href> rewritten through the inboxy redirector. Original publisher URLs are not returned."},"content_text":{"type":["string","null"]}}}},"responses":{"Unauthorized":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Forbidden":{"description":"Token lacks the required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"No matching resource owned by the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"BadRequest":{"description":"Malformed request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"paths":{"/v1/health":{"get":{"tags":["Service"],"summary":"Liveness probe.","description":"Cheap path (default) returns worker liveness only — safe for uptime monitors to poll. Pass `?deep=1` to additionally probe D1 + R2 with one bounded read each; the response then includes per-component `checks` and downgrades `status` to `degraded` when any component fails.","security":[],"parameters":[{"name":"deep","in":"query","description":"Set to `1` to run downstream binding probes. Adds D1 + R2 latency per call.","schema":{"type":"string","enum":["1"]}}],"responses":{"200":{"description":"Liveness OK; `status: degraded` if a deep probe failed.","content":{"application/json":{"schema":{"type":"object","required":["status","env","public_origin"],"properties":{"status":{"type":"string","enum":["ok","degraded"]},"env":{"type":"string"},"public_origin":{"type":"string"},"checks":{"type":"object","description":"Present only on `?deep=1`. One entry per probed binding.","additionalProperties":{"type":"string","enum":["ok","degraded"]}}}}}}}}}},"/v1/account":{"get":{"tags":["Account"],"summary":"Get caller's account snapshot — user, quota, rate-limits, retention.","description":"Returns one blob agents can use to decide whether an action is allowed (e.g. compare `quota.feeds.used` against `quota.feeds.limit` before calling `feed_add`). Same shape on api and mcp.","responses":{"200":{"description":"Account snapshot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountSnapshot"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/billing/checkout":{"post":{"tags":["Billing"],"summary":"Start a Stripe Checkout session for a paid plan.","description":"Requires `manage` scope. Refuses if the account already has an active subscription (`already_subscribed`, 409 — use `/v1/billing/change-plan` to switch tiers instead). `plan_id` must name a real, paid, currently-purchasable plan (`bad_request`, 400, for an unknown or free plan_id; `plan_unavailable`, 404, for a real paid plan with no `stripe_price_id` yet). The billing cycle anchor is pinned to the next 1st-of-the-month UTC boundary regardless of when checkout completes.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingCheckoutRequest"}}}},"responses":{"200":{"description":"Checkout session created — redirect the caller to `url`.","content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"`plan_unavailable` — plan exists but isn't purchasable yet."},"409":{"description":"`already_subscribed` — use `/v1/billing/change-plan` instead."}}}},"/v1/billing/portal":{"post":{"tags":["Billing"],"summary":"Open a Stripe billing portal session for the caller.","description":"Requires `manage` scope. No request body. Refuses if the account has never checked out (`no_subscription`, 409 — no `stripe_customer_id` on file yet).","responses":{"200":{"description":"Portal session created — redirect the caller to `url`.","content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri"}}}}}},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"`no_subscription` — no billing account yet; checkout first."}}}},"/v1/billing/change-plan":{"post":{"tags":["Billing"],"summary":"Switch the caller's existing subscription to a different paid plan.","description":"Requires `manage` scope. Refuses if the account has no active subscription (`no_subscription`, 409 — use `/v1/billing/checkout` to start one instead). `plan_id` must name a real, paid, currently-purchasable plan different from the account's current one (`bad_request`, 400, for an unknown, free, unpurchasable, or already-active plan_id). Upgrades swap the subscription's price immediately with no proration charge, billed in full next cycle (`effective: \"immediately\"`). Downgrades are scheduled for the next renewal so the caller keeps what they already paid for until the period ends (`effective: \"at_period_end\"`).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingChangePlanRequest"}}}},"responses":{"200":{"description":"Plan change applied or scheduled.","content":{"application/json":{"schema":{"type":"object","required":["ok","effective"],"properties":{"ok":{"type":"boolean","enum":[true]},"effective":{"type":"string","enum":["immediately","at_period_end"]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"`no_subscription` — no active subscription; checkout first."}}}},"/v1/addresses":{"get":{"tags":["Addresses"],"summary":"List your aliases.","parameters":[{"name":"include_retired","in":"query","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Aliases.","content":{"application/json":{"schema":{"type":"object","properties":{"addresses":{"type":"array","items":{"$ref":"#/components/schemas/Address"}}}}}}}}},"post":{"tags":["Addresses"],"summary":"Mint a new alias.","description":"Requires `manage` scope. `type` picks the shape: `standard` (default, `adjective-noun-NN`), `agent` (`agent-xxxxxxxx`, flagged `kind: agent`) or `domain` (`yourdomain.com-xxxxxxxx`, flagged `kind: agent`, needs `domain_id` or `domain` naming one of your verified domains — `invalid_domain`, 400 otherwise). Standard aliases are gated by the open-address count cap (`cap_reached`, 403, body carries `used`/`limit`) and the Early-Adopter rotation cooldown (`rotation_cooldown`, 429, body carries `retry_after_sec`/`next_allowed_at`). `agent` and `domain` aliases share a separate agent allowance (`cap_reached`, 403, `pool: \"agent\"`) and skip the cooldown.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddressCreate"}}}},"responses":{"201":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Address"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/v1/addresses/{id}":{"delete":{"tags":["Addresses"],"summary":"Retire an alias.","description":"Requires `manage` scope. Stops accepting new mail; existing messages remain visible.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Retired."},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/addresses/{id}/attribution":{"post":{"tags":["Addresses"],"summary":"Record where an alias was used (browser-extension flow).","description":"Accepts either `manage` or the narrow `extension` scope. Idempotent on `(address_id, site_origin)` — re-recording bumps `last_used_at` and returns `200`; the first record returns `201`. Address-scoped keys can only attribute the alias they're bound to. Writes an `audit_log` row with action `api_address_attribution`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddressAttributionRequest"}}}},"responses":{"200":{"description":"Attribution updated (existing row).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddressAttribution"}}}},"201":{"description":"Attribution recorded (new row).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddressAttribution"}}}},"400":{"description":"`invalid_origin` — body missing or origin malformed."},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/webhooks":{"get":{"tags":["Webhooks"],"summary":"List your webhooks.","description":"Never includes the signing secret — only mint/rotate responses do.","responses":{"200":{"description":"Webhooks.","content":{"application/json":{"schema":{"type":"object","properties":{"webhooks":{"type":"array","items":{"$ref":"#/components/schemas/Webhook"}}}}}}}}},"post":{"tags":["Webhooks"],"summary":"Register a webhook endpoint.","description":"Requires `manage` scope on a paid plan (`paid_feature`, 403, on free). Gated by the plan's `webhook_limit` (`cap_reached`, 403, body carries `used`/`limit`). `url` must be HTTPS and is SSRF-checked (`invalid_url`, 400) before the row is created. The response's `secret` is shown once — store it; it signs the `X-Inboxy-Signature` header on every delivery.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookCreate"}}}},"responses":{"201":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Webhook"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/v1/webhooks/{id}":{"delete":{"tags":["Webhooks"],"summary":"Delete a webhook.","description":"Requires `manage` scope.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Deleted."},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/webhooks/{id}/rotate-secret":{"post":{"tags":["Webhooks"],"summary":"Rotate a webhook's signing secret.","description":"Requires `manage` scope. Invalidates the previous secret immediately; the new value is shown once in this response.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Rotated.","content":{"application/json":{"schema":{"type":"object","required":["secret"],"properties":{"secret":{"type":"string"}}}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/domains":{"get":{"tags":["Domains"],"summary":"List your verified sending domains.","description":"Verification only — a verified domain cannot yet be used to send mail. Unlike a webhook's signing secret, `dns_records.txt.value` is not a secret and is included on every response, not just at creation.","responses":{"200":{"description":"Domains.","content":{"application/json":{"schema":{"type":"object","properties":{"domains":{"type":"array","items":{"$ref":"#/components/schemas/VerifiedDomain"}}}}}}}}},"post":{"tags":["Domains"],"summary":"Register a domain for verification.","description":"Requires `manage` scope. Gated by the plan's `verified_domain_limit` (`cap_reached`, 403, body carries `used`/`limit`). `domain` is validated as a bare hostname (`invalid_domain`, 400); a domain already claimed by any inboxy account is rejected as a conflict (`already_claimed`, 409 — a deliberate divergence from most other create endpoints' single 400 reason, since this is a conflict with existing state rather than a malformed request). The response's `dns_records` are the TXT/SPF/DKIM records to publish; verification runs on a periodic sweep or on-demand via `POST /v1/domains/{id}/verify`. Verification only — a verified domain cannot yet be used to send mail.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifiedDomainCreate"}}}},"responses":{"201":{"description":"Created, in `pending` status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifiedDomain"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"`already_claimed` — this domain is already registered by an inboxy account."}}}},"/v1/domains/{id}":{"delete":{"tags":["Domains"],"summary":"Delete a verified domain.","description":"Requires `manage` scope.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Deleted."},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/domains/{id}/rotate-token":{"post":{"tags":["Domains"],"summary":"Rotate a domain's verification token.","description":"Requires `manage` scope. The previous TXT challenge value stops being accepted immediately; publish the new value at `_inboxy-challenge.{domain}` and re-verify. Resets verification progress — a `verified` domain is demoted to `pending` until it re-earns verification with the new token.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Rotated.","content":{"application/json":{"schema":{"type":"object","required":["verify_token"],"properties":{"verify_token":{"type":"string"}}}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/domains/{id}/verify":{"post":{"tags":["Domains"],"summary":"Run a DNS check for this domain now.","description":"Requires `manage` scope. Rate-limited to 1 request per 60 s per domain id (shared with the account UI's identical action). Queries live DNS for the TXT challenge, SPF, and DKIM CNAME records and persists the result; three consecutive passing checks move the domain to `verified`, two consecutive failures after that demote it back to `verification_lost`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Check run; current state.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifiedDomain"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"description":"Rate limit hit — at most one verify-now check per domain per 60 s."}}}},"/v1/messages":{"get":{"tags":["Messages"],"summary":"List messages, newest first.","parameters":[{"name":"address_id","in":"query","schema":{"type":"string","format":"uuid"}},{"name":"classification","in":"query","schema":{"$ref":"#/components/schemas/Classification"}},{"name":"read","in":"query","schema":{"type":"boolean"}},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor from a previous `next_cursor`."}],"responses":{"200":{"description":"Page of messages.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessagesPage"}}}}}}},"/v1/messages/count":{"get":{"tags":["Messages"],"summary":"Count messages matching a filter.","description":"Same filter parameters as `/v1/messages`. Returns `{ total, unread }`; useful for a badge or pagination decision without walking the cursor.","parameters":[{"name":"address_id","in":"query","schema":{"type":"string","format":"uuid"}},{"name":"classification","in":"query","schema":{"$ref":"#/components/schemas/Classification"}},{"name":"read","in":"query","schema":{"type":"boolean"}},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"Aggregate counts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CountResult"}}}}}}},"/v1/messages/{id}":{"get":{"tags":["Messages"],"summary":"Get one message: metadata, parsed text body, headers, attachments, image refs.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageDetail"}}}},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"tags":["Messages"],"summary":"Permanently delete a message (cascade attachments + embedding).","description":"Requires `manage` scope. Irreversible. Refuses with 409 `kept_must_unkeep_first` if the message has `kept_at` set — call `/v1/messages/{id}/unkeep` first.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Deleted."},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"`kept_must_unkeep_first` — unkeep before deleting."}}}},"/v1/messages/{id}/images":{"get":{"tags":["Messages"],"summary":"Detailed image refs for one message.","description":"Returns per-image `width`/`height` hints and (for inline cid: images) the matching `attachment_id`. Original third-party URLs never leave the worker — only signed `proxy_url`s.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Image refs.","content":{"application/json":{"schema":{"type":"object","properties":{"images":{"type":"array","items":{"$ref":"#/components/schemas/ImageRef"}}}}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/messages/{id}/raw":{"get":{"tags":["Messages"],"summary":"Issue a signed download URL for the raw RFC822 .eml.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Signed URL.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignedUrl"}}}},"404":{"$ref":"#/components/responses/NotFound"},"410":{"description":"Body purged/lost.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/messages/{id}/read":{"post":{"tags":["Messages"],"summary":"Mark a message as read.","description":"Requires `manage` scope.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"read":{"type":"boolean","enum":[true]}}}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/messages/{id}/unread":{"post":{"tags":["Messages"],"summary":"Mark a message as unread.","description":"Requires `manage` scope.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"read":{"type":"boolean","enum":[false]}}}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/messages/read-all":{"post":{"tags":["Messages"],"summary":"Mark every unread message matching a filter as read.","description":"Requires `manage` scope. Body is an optional filter — `address_id`, `classification`, and/or `before` (ISO-8601, only messages received strictly before this instant). Empty body marks every unread message read. Idempotent: re-running returns `{ marked: 0 }` once the set is empty.","requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkAllMessagesRequest"}}}},"responses":{"200":{"description":"Update succeeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkAllResult"}}}},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/v1/messages/{id}/reply":{"post":{"tags":["Messages"],"summary":"Send a reply to the ORIGINAL SENDER of a stored message.","description":"Requires `send` scope. Recipient is locked server-side to `messages.from_email`; the From header is `<alias>@inboxy.net` (alias the original was delivered to). Body cap: 2000 chars. Refused on spam-classified messages, messages older than 30 days, or when the per-user reply rate cap is hit (5/hour, 20/day, 1 per 60s). Every reply writes an `audit_log` row with the SHA-256 of the recipient. Optional `attachment_ids[]` references prior `POST /v1/outbox-attachments` uploads (up to 5 files, 20 MB each, 20 MB total).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyRequest"}}}},"responses":{"200":{"description":"Reply queued.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyResult"}}}},"400":{"description":"Validation failure (spam_blocked, stale_message, body_too_long, …)."},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"410":{"description":"Alias for this message no longer exists."},"429":{"description":"Rate limit hit. Body includes `scope` + `retry_after_sec`."}}}},"/v1/messages/{id}/keep":{"post":{"tags":["Messages"],"summary":"Pin a message so the retention sweep won't delete it.","description":"Cap of 10 currently-kept per user. Idempotent — re-keeping returns the existing `kept_at`. Writes an `audit_log` row with action `api_message_kept`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Kept (or already-kept).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeepResult"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"`keep_limit_reached` — cap depends on your plan. Unkeep one to free a slot."}}}},"/v1/messages/{id}/unkeep":{"post":{"tags":["Messages"],"summary":"Remove the retention pin from a message.","description":"Idempotent. Returns `{ id, kept_at: null }` whether or not the message was previously kept.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Unkept (or already-unkept).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeepResult"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/messages/{id}/unsubscribe":{"get":{"tags":["Messages"],"summary":"Surface unsubscribe methods for a message.","description":"Parses the `List-Unsubscribe` header (RFC 2369 — mailto + URLs), the `List-Unsubscribe-Post: List-Unsubscribe=One-Click` flag (RFC 8058), and body `<a>` links whose anchor text or URL path matches `/unsubscribe|opt-out/i`. **Read-only** — the server does NOT visit or POST the URL. The caller decides what to do with `recommended` (one_click_post is the only auto-safe option; mailto goes through the user's normal sender path; generic http links must be opened by hand).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Parsed unsubscribe info.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnsubscribeInfo"}}}},"404":{"$ref":"#/components/responses/NotFound"},"410":{"description":"Body lost, purged, or unparseable."}}}},"/v1/outbox":{"get":{"tags":["Outbox"],"summary":"List sent replies.","description":"Newest first. Use `before` (ISO-8601) to paginate by send time. Filter by `status` / `kind` / `in_reply_to` for scoped listings.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"before","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"status","in":"query","schema":{"type":"string","enum":["queued","sent","failed","rejected","suppressed"]}},{"name":"kind","in":"query","schema":{"type":"string","enum":["reply","forward","digest"]}},{"name":"in_reply_to","in":"query","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"List of outbox rows (body fields elided).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutboxList"}}}}}}},"/v1/outbox/count":{"get":{"tags":["Outbox"],"summary":"Count sent replies matching the filter.","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["queued","sent","failed","rejected","suppressed"]}},{"name":"kind","in":"query","schema":{"type":"string","enum":["reply","forward","digest"]}}],"responses":{"200":{"description":"Total.","content":{"application/json":{"schema":{"type":"object","required":["total"],"properties":{"total":{"type":"integer","minimum":0}}}}}}}}},"/v1/outbox/{id}":{"get":{"tags":["Outbox"],"summary":"Get one sent reply, including body_text + body_html.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Outbox row.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutboxMessage"}}}},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"tags":["Outbox"],"summary":"Permanently delete a sent reply. Kept rows must be unkept first.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Deleted."},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Row is currently kept — unkeep first."}}}},"/v1/read-all":{"post":{"tags":["Messages"],"summary":"Mark every unread mail AND every unread article read in one call.","description":"Requires `manage` scope. Optional `before` (ISO-8601) scopes to items received/published strictly before that instant. Returns `{ mail_marked, articles_marked }`.","requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkAllCombinedRequest"}}}},"responses":{"200":{"description":"Update succeeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkAllCombinedResult"}}}},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/v1/messages/search":{"post":{"tags":["Messages"],"summary":"Semantic search across your inbox.","description":"Embeds the query with the same model the classifier writes with, then runs a vector query against your messages. `user_id` filter is mandatory and applied server-side — you cannot omit or override it.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchRequest"}}}},"responses":{"200":{"description":"Ranked matches.","content":{"application/json":{"schema":{"type":"object","properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/SearchResult"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/v1/attachments/{id}":{"get":{"tags":["Attachments"],"summary":"Issue a signed download URL for one attachment.","description":"The returned `url` is publicly fetchable until `expires_at` — the signed token is the auth. Images are served `Content-Disposition: inline` so they render in `<img>` contexts.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Signed URL + metadata.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignedUrl"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/outbox-attachments":{"post":{"tags":["Attachments"],"summary":"Upload one attachment for use in a future reply.","description":"Body is the raw file bytes. Returns an `id` to pass in `ReplyRequest.attachment_ids`. Caps: 20 MB per file; per-user limits on unsent uploads. Use `X-Filename` to set the filename the recipient sees.","requestBody":{"required":true,"content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"parameters":[{"name":"X-Filename","in":"header","schema":{"type":"string","maxLength":200},"description":"Original filename. Sanitised before being placed in the outbound MIME part."}],"responses":{"200":{"description":"Upload accepted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutboxAttachmentUploadResult"}}}},"400":{"description":"Empty body or missing Content-Type."},"413":{"description":"Attachment exceeds the 20 MB cap."},"429":{"description":"Per-user unsent-uploads cap reached."}}}},"/v1/outbox-attachments/{id}":{"delete":{"tags":["Attachments"],"summary":"Release an unsent attachment.","description":"Deletes the R2 object and the D1 row. Refused once the sender has consumed the attachment in a sent reply.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deleted."},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Attachment was already consumed in a sent reply."}}}},"/v1/mail/send":{"post":{"tags":["Mail"],"summary":"Send a composed message from one of your aliases.","description":"REST mirror of the MCP `mail_send` tool. Requires `send` scope. The `From:` is locked to one of the caller's active `@inboxy.net` aliases (forced to the bound alias on address-scoped keys). Recipients are classified internal vs external; external sends enqueue onto `outbound-send` and carry the inboxy attribution footer. Plan-tier daily caps + per-user rate limits apply.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MailSendRequest"}}}},"responses":{"202":{"description":"Accepted — internal delivery completed, external queued.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MailSendResult"}}}},"400":{"description":"Validation failure (`from_missing`, `from_not_owned`, `from_retired`, `to_missing`, `subject_missing`, `body_missing`, `body_too_long`, …)."},"403":{"description":"`key_scope_mismatch` (address-scoped key called with a different `from_local_part`) or recipient blocked by abuse rules."},"429":{"description":"`quota_exhausted` — body includes `code` + `remaining`. The mail-send quota refills daily."}}}},"/v1/mail/import":{"post":{"tags":["Mail"],"summary":"Import a historic email onto one of your aliases.","description":"REST mirror of the MCP `mail_import` tool. Requires `manage` scope. Push one raw RFC 5322 message (an `.eml`) that never arrived at inboxy — old order mail from another mailbox, say — and it is stored, classified and embedded for `mail_search` exactly like inbound mail. It is an archive write: never forwarded, never digested, stored as read, and its retention clock starts at import rather than at its own `Date:`. Mail whose `From:` is your own sign-in address is accepted and flagged `sent_by_user`. Dedup is per user on `Message-ID:`. Capped per UTC calendar month (500 free / 5000 paid); 10 MiB per message.","parameters":[{"name":"address_id","in":"query","required":false,"schema":{"type":"string","format":"uuid"},"description":"Row id of one of your active addresses; the message is filed under it. Give this or `address`. Address-scoped keys must name their bound address."},{"name":"address","in":"query","required":false,"schema":{"type":"string"},"description":"The alias by name — `<local>@inboxy.net` or just the local-part, case-insensitive. Alternative to `address_id`; if both are given they must agree."}],"requestBody":{"required":true,"description":"The raw message bytes, unencoded.","content":{"message/rfc822":{"schema":{"type":"string","format":"binary"}},"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"responses":{"200":{"description":"Already stored (`duplicate: true`); nothing changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MailImportResult"}}}},"201":{"description":"Stored and queued for classification + embedding.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MailImportResult"}}}},"400":{"description":"`bad_request` (neither `address_id` nor `address`, an `address` not on inboxy.net, or the two disagreeing) or `unparseable` (no `From:` address)."},"403":{"description":"Address not yours or retired, or `key_scope_mismatch` on an address-scoped key."},"413":{"description":"`too_large` — over 10 MiB."},"429":{"description":"`import_quota_exhausted` — body includes `period_resets_at`."}}}},"/v1/mail/invite":{"post":{"tags":["Mail"],"summary":"Invite a friend to inboxy.","description":"REST mirror of the MCP `mail_invite` tool. Requires `send` scope. One magic-link email is sent from `invites@mail.inboxy.net`. Capped at 10 per rolling 30 days per inviter. Refuses self-invites, existing-user invites, and reserved local-parts.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MailInviteRequest"}}}},"responses":{"200":{"description":"Invite queued; magic-link valid 30 days.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MailInviteResult"}}}},"400":{"description":"Validation failure (`to_missing`, `to_unparseable`, `recipient_reserved`, `self_invite`, `note_too_long`, `note_has_link`)."},"403":{"description":"`recipient_already_user` — the invitee already has an inboxy account."},"429":{"description":"`invite_cap_reached` — body includes `remaining: 0` + `resets_at` (Unix seconds)."}}}},"/v1/feeds":{"get":{"tags":["Feeds"],"summary":"List your feed subscriptions.","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["active","error","inactive"]}}],"responses":{"200":{"description":"Feeds.","content":{"application/json":{"schema":{"type":"object","properties":{"feeds":{"type":"array","items":{"$ref":"#/components/schemas/Feed"}}}}}}}}},"post":{"tags":["Feeds"],"summary":"Subscribe to a feed by URL.","description":"Requires `manage` scope. The same source URL is shared across all subscribers (one fetch serves everyone) — but your label and read-state stay private.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedCreate"}}}},"responses":{"201":{"description":"Subscribed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Feed"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"Already subscribed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/feeds/{id}":{"get":{"tags":["Feeds"],"summary":"Get one subscription.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Feed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Feed"}}}},"404":{"$ref":"#/components/responses/NotFound"}}},"patch":{"tags":["Feeds"],"summary":"Update a subscription's label.","description":"Requires `manage` scope.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedPatch"}}}},"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":["string","null"]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"tags":["Feeds"],"summary":"Unsubscribe from a feed.","description":"Requires `manage` scope. Other subscribers (if any) keep the feed; your read-state for it is deleted.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Unsubscribed."},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/feeds/{id}/refresh":{"post":{"tags":["Feeds"],"summary":"Trigger a manual re-poll of one feed.","description":"Requires `manage` scope. Returns immediately; new articles land within ~30s. The poll benefits every subscriber to the underlying source.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"202":{"description":"Queued.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"queued":{"type":"boolean","enum":[true]}}}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/feeds/{id}/articles":{"get":{"tags":["Feeds"],"summary":"List articles in one feed, newest first.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"unread","in":"query","schema":{"type":"boolean"}},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":25}},{"name":"cursor","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Page of articles.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ArticlesPage"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/feeds/{id}/articles/count":{"get":{"tags":["Feeds"],"summary":"Count articles in one feed.","description":"Returns `{ total, unread }` for the subscription.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"Aggregate counts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CountResult"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/articles":{"get":{"tags":["Articles"],"summary":"List articles across all your feeds, newest first.","parameters":[{"name":"feed_id","in":"query","schema":{"type":"string","format":"uuid"}},{"name":"unread","in":"query","schema":{"type":"boolean"}},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":25}},{"name":"cursor","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Page of articles.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ArticlesPage"}}}}}}},"/v1/articles/count":{"get":{"tags":["Articles"],"summary":"Count articles across your feeds.","description":"Returns `{ total, unread }` over the user's joined article set; optional `feed_id` scopes to one subscription.","parameters":[{"name":"feed_id","in":"query","schema":{"type":"string","format":"uuid"}},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"Aggregate counts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CountResult"}}}}}}},"/v1/articles/{id}":{"get":{"tags":["Articles"],"summary":"Get one article with link-rewritten HTML and plain-text excerpt.","description":"Every `<a href>` in `content_html` is rewritten through the inboxy redirector (apps/link). Original publisher URLs are not returned.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Article.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ArticleDetail"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/articles/{id}/read":{"post":{"tags":["Articles"],"summary":"Mark an article as read.","description":"Requires `manage` scope.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"read":{"type":"boolean","enum":[true]}}}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/articles/{id}/unread":{"post":{"tags":["Articles"],"summary":"Mark an article as unread.","description":"Requires `manage` scope.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"read":{"type":"boolean","enum":[false]}}}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/articles/read-all":{"post":{"tags":["Articles"],"summary":"Mark every unread article matching a filter as read.","description":"Requires `manage` scope. Body is an optional filter — `feed_id` scopes to one subscription; `before` (ISO-8601) only marks articles published strictly before this instant. Empty body marks every unread article read. Idempotent.","requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkAllArticlesRequest"}}}},"responses":{"200":{"description":"Update succeeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkAllResult"}}}},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/v1/keys":{"get":{"tags":["API keys"],"summary":"List your API keys (no plaintext).","description":"Mint is web-only at https://inboxy.net/account/keys behind a step-up gate; there is no POST on this path.","responses":{"200":{"description":"Keys.","content":{"application/json":{"schema":{"type":"object","properties":{"keys":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}}}}}}}}}},"/v1/keys/{id}":{"delete":{"tags":["API keys"],"summary":"Retire an API key.","description":"Requires `manage` scope. The key stops working immediately.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Retired."},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}}}}