{"openapi":"3.1.0","info":{"version":"1.0.0","title":"@zippy/api","description":"Zippy Cloud dashboard API."},"components":{"securitySchemes":{"apiKey":{"type":"http","scheme":"bearer","bearerFormat":"zip_","description":"A Zippy API key: `Authorization: Bearer zip_…`. Mint one in the dashboard (Legend tier). Cookies are NEVER accepted on /v1."}},"schemas":{"V1Me":{"type":"object","properties":{"orgId":{"type":"string","format":"uuid"},"tier":{"type":"string","enum":["sidekick","hero","legend"]},"features":{"$ref":"#/components/schemas/V1Features"},"rateLimit":{"type":"object","properties":{"limit":{"type":"integer"},"windowSeconds":{"type":"integer"}},"required":["limit","windowSeconds"]}},"required":["orgId","tier","features","rateLimit"]},"V1Features":{"type":"object","properties":{"customSlugs":{"type":"boolean"},"fullAnalytics":{"type":"boolean"},"analyticsExport":{"type":"boolean"},"removeBranding":{"type":"boolean"},"api":{"type":"boolean"},"bulkCreate":{"type":"boolean"},"editDestination":{"type":"boolean"},"platformTargeting":{"type":"boolean"},"qrCodes":{"type":"boolean"},"geoRouting":{"type":"boolean"},"passwordLinks":{"type":"boolean"},"folders":{"type":"boolean"},"qrCustomization":{"type":"boolean"},"richFallback":{"type":"boolean"},"pixelInjection":{"type":"boolean"},"webhooks":{"type":"boolean"},"audienceSegments":{"type":"boolean"},"audienceCapture":{"type":"boolean"},"broadcasts":{"type":"boolean"},"abTesting":{"type":"boolean"},"scheduledLinks":{"type":"boolean"},"linkInBio":{"type":"boolean"},"bioCustomTheme":{"type":"boolean"},"mediaKit":{"type":"boolean"},"dmAutomations":{"type":"boolean"}},"required":["customSlugs","fullAnalytics","analyticsExport","removeBranding","api","bulkCreate","editDestination","platformTargeting","qrCodes","geoRouting","passwordLinks","folders","qrCustomization","richFallback","pixelInjection","webhooks","audienceSegments","audienceCapture","broadcasts","abTesting","scheduledLinks","linkInBio","bioCustomTheme","mediaKit","dmAutomations"]},"V1Error":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Human-readable explanation."},"issues":{"type":"array","items":{"nullable":true},"description":"Validation issues — present on a 400 from a body parse."},"retryAfter":{"type":"integer","description":"Seconds to wait. 429 only."}},"required":["error"]},"V1Link":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"hostname":{"type":"string","nullable":true,"description":"Custom domain, or null for the default host."},"shortUrl":{"type":"string","description":"The full short URL, ready to paste. Map this one field when your automation (ManyChat External Request, Zapier, Make) can only bind a single JSONPath."},"destination":{"type":"string"},"platformKey":{"type":"string","nullable":true},"branded":{"type":"boolean","description":"Whether the Zippy interstitial shows on the redirect."},"readOnly":{"type":"boolean","description":"Over the plan's link cap after a downgrade — STILL REDIRECTS."},"utm":{"type":"object","nullable":true,"properties":{"source":{"type":"string","maxLength":200},"medium":{"type":"string","maxLength":200},"campaign":{"type":"string","maxLength":200},"term":{"type":"string","maxLength":200},"content":{"type":"string","maxLength":200}}},"routing":{"type":"object","nullable":true,"properties":{"ios":{"type":"string","maxLength":4096},"android":{"type":"string","maxLength":4096},"desktop":{"type":"string","maxLength":4096},"geo":{"type":"object","additionalProperties":{"type":"string","maxLength":4096}}}},"ab":{"type":"array","nullable":true,"items":{"type":"object","properties":{"u":{"type":"string","maxLength":4096},"w":{"type":"number","minimum":0,"exclusiveMinimum":true}},"required":["u","w"]},"minItems":2,"maxItems":4},"sched":{"type":"array","nullable":true,"items":{"type":"object","properties":{"from":{"type":"integer"},"url":{"type":"string","maxLength":4096}},"required":["from","url"]},"maxItems":10},"hasPassword":{"type":"boolean","description":"The hash itself is never returned."},"tags":{"type":"array","items":{"type":"string"}},"folderId":{"type":"string","nullable":true,"format":"uuid"},"pixelIds":{"type":"array","items":{"type":"string","format":"uuid"}},"captureEmails":{"type":"boolean"},"createdAt":{"type":"string","description":"ISO-8601 instant, UTC."},"updatedAt":{"type":"string","description":"ISO-8601 instant, UTC."}},"required":["id","slug","hostname","shortUrl","destination","platformKey","branded","readOnly","utm","routing","ab","sched","hasPassword","tags","folderId","pixelIds","captureEmails","createdAt","updatedAt"]},"V1BulkResult":{"type":"object","properties":{"results":{"type":"array","items":{"anyOf":[{"$ref":"#/components/schemas/V1Link"},{"$ref":"#/components/schemas/V1Error"}]}}},"required":["results"]},"V1Archived":{"type":"object","properties":{"archived":{"type":"boolean","enum":[true]},"note":{"type":"string"}},"required":["archived","note"]},"V1OgPreview":{"type":"object","properties":{"title":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"image":{"type":"string","nullable":true},"source":{"type":"string","enum":["scraped","custom"]},"scraped":{"type":"object","nullable":true,"properties":{"title":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"image":{"type":"string","nullable":true}},"required":["title","description","image"]},"updatedAt":{"type":"string","nullable":true}},"required":["title","description","image","source","scraped","updatedAt"]},"V1LinkStats":{"type":"object","properties":{"configured":{"type":"boolean","description":"false = the analytics store is not wired up; every number is a placeholder zero."},"total":{"type":"number"},"appOpenRate":{"type":"number","nullable":true,"description":"SAMPLED estimate. null = no outcome data yet — never render it as 0."},"outcomes":{"$ref":"#/components/schemas/V1Outcomes"}},"required":["configured","total","appOpenRate","outcomes"]},"V1Outcomes":{"type":"object","properties":{"opened":{"type":"number"},"browser":{"type":"number"},"broken":{"type":"number"}},"required":["opened","browser","broken"]},"V1Clicks":{"type":"object","properties":{"configured":{"type":"boolean","description":"false = the analytics store is not wired up; every number is a placeholder zero."},"clicks":{"type":"array","items":{"$ref":"#/components/schemas/V1Click"}},"upgrade":{"type":"string","description":"Set when the tier can't see this data yet."}},"required":["configured","clicks"]},"V1Click":{"type":"object","properties":{"id":{"type":"string"},"at":{"type":"string","description":"ISO-8601 instant, UTC."},"outcome":{"type":"string"},"sourceApp":{"type":"string","nullable":true},"country":{"type":"string","nullable":true},"city":{"type":"string","nullable":true},"device":{"type":"string","nullable":true}},"required":["id","at","outcome","sourceApp","country","city","device"]},"V1AbStats":{"type":"object","properties":{"configured":{"type":"boolean","description":"false = the analytics store is not wired up; every number is a placeholder zero."},"sampled":{"type":"boolean","enum":[true]},"window":{"type":"integer","description":"Look-back window, days."},"variants":{"type":"array","items":{"$ref":"#/components/schemas/V1AbVariant"}},"upgrade":{"type":"string","description":"Set when the tier can't see this data yet."}},"required":["configured","sampled","window","variants"]},"V1AbVariant":{"type":"object","properties":{"url":{"type":"string"},"weight":{"type":"number"},"variant":{"type":"integer","description":"0-based index into the link's `ab` array."},"clicks":{"type":"number"},"outcomes":{"type":"number"},"opened":{"type":"number"},"appOpenRate":{"type":"number","nullable":true,"description":"SAMPLED estimate. null = no outcome data yet — never render it as 0."}},"required":["url","weight","variant","clicks","outcomes","opened","appOpenRate"]},"V1Folder":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"linkCount":{"type":"integer"},"createdAt":{"type":"string","description":"ISO-8601 instant, UTC."}},"required":["id","name","linkCount","createdAt"]},"V1FolderRenamed":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"createdAt":{"type":"string","description":"ISO-8601 instant, UTC."}},"required":["id","name","createdAt"]},"V1Deleted":{"type":"object","properties":{"deleted":{"type":"boolean","enum":[true]},"note":{"type":"string"}},"required":["deleted"]},"V1Pixel":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["meta","tiktok","gtm"]},"pixelId":{"type":"string"},"name":{"type":"string"},"createdAt":{"type":"string","description":"ISO-8601 instant, UTC."}},"required":["id","type","pixelId","name","createdAt"]},"V1BioEnvelope":{"type":"object","properties":{"page":{"$ref":"#/components/schemas/V1BioPage"}},"required":["page"]},"V1BioPage":{"type":"object","nullable":true,"properties":{"id":{"type":"string","format":"uuid"},"handle":{"type":"string"},"title":{"type":"string"},"bio":{"type":"string","nullable":true},"avatarUrl":{"type":"string","nullable":true},"theme":{"type":"object","nullable":true,"properties":{"bg":{"type":"string"},"ink":{"type":"string"},"accent":{"type":"string"}}},"blocks":{"type":"array","items":{"$ref":"#/components/schemas/V1BioBlock"}},"published":{"type":"boolean"},"createdAt":{"type":"string","description":"ISO-8601 instant, UTC."},"updatedAt":{"type":"string","description":"ISO-8601 instant, UTC."}},"required":["id","handle","title","bio","avatarUrl","theme","blocks","published","createdAt","updatedAt"]},"V1BioBlock":{"oneOf":[{"type":"object","properties":{"t":{"type":"string","enum":["link"]},"id":{"type":"string"},"linkId":{"type":"string","format":"uuid"},"label":{"type":"string"},"note":{"type":"string"}},"required":["t","id","linkId","label"]},{"type":"object","properties":{"t":{"type":"string","enum":["header"]},"id":{"type":"string"},"text":{"type":"string"}},"required":["t","id","text"]},{"type":"object","properties":{"t":{"type":"string","enum":["text"]},"id":{"type":"string"},"text":{"type":"string"}},"required":["t","id","text"]},{"type":"object","properties":{"t":{"type":"string","enum":["social"]},"id":{"type":"string"},"items":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"linkId":{"type":"string","format":"uuid"}},"required":["key","linkId"]}}},"required":["t","id","items"]}]},"V1BioSaved":{"type":"object","properties":{"page":{"$ref":"#/components/schemas/V1BioPage"}},"required":["page"]},"V1BioStats":{"type":"object","properties":{"configured":{"type":"boolean","description":"false = the analytics store is not wired up; every number is a placeholder zero."},"sampled":{"type":"boolean","enum":[true]},"window":{"type":"integer"},"blocks":{"type":"array","items":{"type":"object","properties":{"linkId":{"type":"string","format":"uuid"},"clicks":{"type":"number"},"outcomes":{"type":"number"},"opened":{"type":"number"},"appOpenRate":{"type":"number","nullable":true,"description":"SAMPLED estimate. null = no outcome data yet — never render it as 0."}},"required":["linkId","clicks","outcomes","opened","appOpenRate"]}},"upgrade":{"type":"string","description":"Set when the tier can't see this data yet."}},"required":["configured","sampled","window","blocks"]},"V1Audience":{"type":"object","properties":{"count":{"type":"integer","description":"The org's TOTAL contacts, not this page's size."},"contacts":{"type":"array","items":{"$ref":"#/components/schemas/V1Contact"}}},"required":["count","contacts"]},"V1Contact":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"sourceSlug":{"type":"string","nullable":true},"country":{"type":"string","nullable":true},"createdAt":{"type":"string","description":"ISO-8601 instant, UTC."}},"required":["id","email","sourceSlug","country","createdAt"]},"V1ContactDetail":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"sourceSlug":{"type":"string","nullable":true},"country":{"type":"string","nullable":true},"createdAt":{"type":"string","description":"ISO-8601 instant, UTC."},"consentText":{"type":"string","description":"The exact wording this person agreed to. Blank = a permanent send refusal."},"unsubscribedAt":{"type":"string","nullable":true,"description":"ISO-8601 instant, UTC."},"sourceLinkId":{"type":"string","nullable":true,"format":"uuid","description":"The link `sourceSlug` belongs to, when it still exists."},"broadcasts":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"subject":{"type":"string"},"status":{"type":"string","description":"The recipient row's own status: queued/sending/sent/failed/skipped."},"reason":{"type":"string","nullable":true,"description":"Creator-language reason when it did not send."},"sentAt":{"type":"string","nullable":true,"description":"ISO-8601 instant, UTC."}},"required":["id","subject","status","reason","sentAt"]}}},"required":["id","email","sourceSlug","country","createdAt","consentText","unsubscribedAt","sourceLinkId","broadcasts"]},"V1Webhook":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"url":{"type":"string"},"events":{"type":"array","items":{"type":"string"}},"active":{"type":"boolean"},"failCount":{"type":"integer"},"lastDeliveredAt":{"type":"string","nullable":true},"createdAt":{"type":"string","description":"ISO-8601 instant, UTC."}},"required":["id","url","events","active","failCount","lastDeliveredAt","createdAt"]},"V1WebhookCreated":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"url":{"type":"string"},"events":{"type":"array","items":{"type":"string"}},"active":{"type":"boolean"},"failCount":{"type":"integer"},"lastDeliveredAt":{"type":"string","nullable":true},"createdAt":{"type":"string","description":"ISO-8601 instant, UTC."},"secret":{"type":"string","description":"The signing secret — returned ONCE, never readable again."},"note":{"type":"string"}},"required":["id","url","events","active","failCount","lastDeliveredAt","createdAt","secret","note"]},"V1Analytics":{"type":"object","properties":{"configured":{"type":"boolean","description":"false = the analytics store is not wired up; every number is a placeholder zero."},"days":{"type":"integer"},"tier":{"type":"string","enum":["sidekick","hero","legend"]},"total":{"type":"number"},"perLink":{"type":"array","items":{"type":"object","properties":{"slug":{"type":"string"},"clicks":{"type":"number"}},"required":["slug","clicks"],"additionalProperties":{"nullable":true}}},"countries":{"type":"array","items":{"type":"object","properties":{"country":{"type":"string"},"clicks":{"type":"number"}},"required":["country","clicks"],"additionalProperties":{"nullable":true}}},"devices":{"type":"array","items":{"type":"object","properties":{"device":{"type":"string"},"clicks":{"type":"number"}},"required":["device","clicks"],"additionalProperties":{"nullable":true}}},"platforms":{"type":"array","items":{"type":"object","properties":{"platform":{"type":"string"},"clicks":{"type":"number"}},"required":["platform","clicks"],"additionalProperties":{"nullable":true}}},"referrers":{"type":"array","items":{"type":"object","properties":{"referrer":{"type":"string"},"clicks":{"type":"number"}},"required":["referrer","clicks"],"additionalProperties":{"nullable":true}}},"timeseries":{"type":"array","items":{"type":"object","properties":{"day":{"type":"string"},"clicks":{"type":"number"}},"required":["day","clicks"],"additionalProperties":{"nullable":true}}},"hourly":{"type":"array","items":{"type":"object","properties":{"hour":{"type":"string"},"clicks":{"type":"number"}},"required":["hour","clicks"],"additionalProperties":{"nullable":true}}},"outcomes":{"$ref":"#/components/schemas/V1Outcomes"},"platformOutcomes":{"type":"array","items":{"type":"object","properties":{"platform":{"type":"string"},"outcome":{"type":"string"},"clicks":{"type":"number"}},"required":["platform","outcome","clicks"],"additionalProperties":{"nullable":true}}},"upgrade":{"type":"string","description":"Set when the tier can't see this data yet."}},"required":["configured","days","tier","total","perLink"]}},"parameters":{}},"paths":{"/api/bio":{"get":{"tags":["bio"],"summary":"The caller's bio page","description":"Returns { page: BioPageRow | null }. Gated on the link-in-bio flag.","responses":{"200":{"description":"The org's page, or null"},"401":{"description":"Unauthorized"},"403":{"description":"Flag off"}}},"put":{"tags":["bio"],"summary":"Claim or update the bio page","description":"Upsert of handle/title/bio/avatarUrl/theme/published. Handle is lowercased, validated against the reserved-word list and unique case-insensitively. `theme` omitted preserves the stored theme; setting one needs the bioCustomTheme tier feature.","responses":{"200":{"description":"The saved page"},"400":{"description":"Invalid body / invalid_handle"},"403":{"description":"link_in_bio, bio_custom_theme or feature_disabled"},"409":{"description":"handle_taken"}}},"delete":{"tags":["bio"],"summary":"Delete the bio page","description":"Ungated (cleanup after a downgrade). The blocks' links keep redirecting.","responses":{"200":{"description":"Deleted"},"404":{"description":"Not found"}}}},"/api/bio/blocks":{"put":{"tags":["bio"],"summary":"Replace the ordered block list (create/update/reorder/delete)","description":"One atomic write of the whole array — the array index is the page order. Every tappable block must carry a linkId owned by the caller's org; a block never carries a URL.","responses":{"200":{"description":"The saved page"},"400":{"description":"invalid_blocks or unknown_link — nothing was written"},"403":{"description":"Tier or flag gate"},"404":{"description":"No page yet"}}}},"/api/bio/stats":{"get":{"tags":["bio"],"summary":"Per-block app-open rate","description":"Returns { configured, sampled: true, window, blocks: [{ linkId, clicks, outcomes, opened, appOpenRate }] }, keyed by linkId so the client joins it onto the block it already has. appOpenRate is null when there are no outcome rows yet (render '—', never 0%). Hero+ (fullAnalytics); Sidekick gets an `upgrade` string and an empty list.","responses":{"200":{"description":"Per-block stats (possibly configured:false)"},"401":{"description":"Unauthorized"}}}},"/api/public/bio/{handle}":{"get":{"tags":["bio"],"summary":"PUBLIC — render a bio page","description":"No auth. Returns handle, title, bio, avatarUrl, theme, updatedAt and the rendered blocks (link blocks expose the SHORT URL, never a linkId or destination). Never returns org, plan, email, page id or published. 404 for unknown, malformed and unpublished handles alike.","responses":{"200":{"description":"The public page"},"404":{"description":"Not found"}}}},"/api/media-kit":{"get":{"tags":["media-kit"],"summary":"The caller's media kit settings + live preview","description":"Returns { kit: MediaKitSettings | null, preview: MediaKitPublic | null }. `kit: null` means never opted in; the preview still renders from MEDIA_KIT_DEFAULT so the creator can see what publishing would show. Without a bio page: { kit: null, preview: null, needsBioPage: true }. Gated on the media-kit flag only.","responses":{"200":{"description":"Settings + preview"},"401":{"description":"Unauthorized"},"403":{"description":"Flag off"}}},"put":{"tags":["media-kit"],"summary":"Publish / unpublish the kit and choose what it shows","description":"Body { published?: boolean, show?: { taps, appOpenRate, platforms, topLinks, trend } }. Default-DENY: anything not strictly `true` is off, and the stored object is fully re-derived (never patched). Hero+ (`mediaKit`) plus the media-kit flag.","responses":{"200":{"description":"Saved settings + preview"},"400":{"description":"Invalid body"},"403":{"description":"media_kit tier gate or feature_disabled"},"404":{"description":"no_bio_page — claim a handle first"}}},"delete":{"tags":["media-kit"],"summary":"Remove the media kit","description":"Ungated (cleanup after a downgrade or a dark flag). Clears the kit column only — the bio page, its blocks and every link are untouched.","responses":{"200":{"description":"Deleted"},"404":{"description":"No bio page"}}}},"/api/public/media-kit/{handle}":{"get":{"tags":["media-kit"],"summary":"PUBLIC — the media kit payload","description":"No auth. ALWAYS returns handle, title, bio, avatarUrl, theme, updatedAt, window, sampled (always true), configured, empty. Each of taps / appOpen / platforms / topLinks / trend is present ONLY if the creator switched that section on AND there is data. NEVER returns the page id, orgId, email, tier/plan, `published`, createdAt, any linkId, any destination, or any per-visitor dimension (country/city/device/referrer). Numbers are sampled-extrapolated whole numbers; a rate is null when there are no outcome rows. Nothing to report → empty:true with the stat fields absent, never zeros. One 404 covers unknown, malformed, unpublished-page, no-kit and unpublished-kit.","responses":{"200":{"description":"The public payload"},"404":{"description":"Not found"}}}},"/api/public/media-kit/{handle}/card.svg":{"get":{"tags":["media-kit"],"summary":"PUBLIC — the app-open-rate card, as an image","description":"The same headline as a 1200x630 SVG for pasting into a brand email or deck (the Wave 2.10 receipt renderer, with a foot line naming the sampling). 404 unless the kit is published AND its appOpenRate section is on; 409 no_data when there are no outcome rows — never a fabricated card.","responses":{"200":{"description":"image/svg+xml"},"404":{"description":"Not found"},"409":{"description":"no_data"}}}},"/api/public/instagram/webhook":{"get":{"tags":["instagram"],"summary":"PUBLIC — Meta's webhook verification handshake","description":"No auth. Meta GETs this once when the callback URL is saved, with `hub.mode=subscribe`, `hub.verify_token` and `hub.challenge`. Echoes the challenge back as RAW TEXT (`text/plain`) — a JSON-quoted body fails the subscription. The token is compared in CONSTANT TIME against META_WEBHOOK_VERIFY_TOKEN; an unset env var fails CLOSED. Any mismatch is 403 and the challenge is never echoed, because echoing an unverified challenge is how a stranger would point their own Meta app's webhooks at us.","responses":{"200":{"description":"The challenge, verbatim, as text/plain"},"403":{"description":"Wrong/absent verify token, or META_WEBHOOK_VERIFY_TOKEN unset (fail closed)"}}},"post":{"tags":["instagram"],"summary":"PUBLIC — Meta's Instagram event delivery (comments + messages)","description":"No auth header by design: `X-Hub-Signature-256` IS the auth. The header is `sha256=<hex>`, an HMAC-SHA256 of the RAW request body keyed by the Meta APP SECRET, and it is verified in constant time BEFORE the body is parsed or trusted — an unsigned or badly-signed request is 403 and nothing else runs. Past that check the endpoint acknowledges with 200 IMMEDIATELY and does the work fire-and-forget, because Meta retries on a slow or non-200 response and eventually disables the callback. Consequently a malformed payload, an unknown Instagram account, and a payload matching no automation are all an indistinguishable 200 + drop, never a 500. Redelivery is deduped on (automationId, sourceId) by a unique index, so one comment can never DM a follower twice. Bodies over 1 MB are 413 (the service-wide cap), and that cap is MEASURED, not merely declared: a request with no content-length — a chunked POST — is drained through a counting reader that aborts past the limit, because trusting the header alone meant an absent one coerced to 0 and an unbounded body reached the HMAC. Message text is used for matching in memory and is never stored or logged.","responses":{"200":{"description":"Acknowledged (also the response for a dropped/unmatched/malformed payload)"},"403":{"description":"Absent, malformed or invalid signature — or META_APP_SECRET unset"},"413":{"description":"Body over the 1 MB cap — whether declared via content-length or measured"}}}},"/internal/instagram-refresh":{"post":{"tags":["instagram"],"summary":"INTERNAL — the Meta long-lived token refresh sweep (cron)","description":"Authenticated by `x-cron-secret` (fail closed: no CRON_SECRET is 503, a wrong header is 401). A scheduler POSTs this daily — the `/internal/drip-tick` precedent, push-driven by the clock rather than polled. WHY IT EXISTS: a Meta long-lived Instagram token lasts 60 days and there is NO separate refresh token — the LIVE token is exchanged for its successor, and Meta refuses to do that for a token younger than 24 hours. A connection that reaches expiry is therefore UNRECOVERABLE and the creator must reconnect from scratch, so without this sweep every connection in the product dies permanently on day 60. Each pass selects live connections at or inside the 7-day horizon, skips `needs_reconnect`/`revoked` accounts and anything under 24h old, and writes the successor token back through the same AES-256-GCM seal path as connect — no code path writes a plaintext token. A refusal from Meta flips the account to `needs_reconnect` with creator-language copy so a dead connection SHOUTS. The response is counts only: `{ scanned, refreshed, needsReconnect, skipped, errors }` — never an id, never a token.","responses":{"200":{"description":"Sweep counts"},"401":{"description":"Wrong or absent x-cron-secret"},"503":{"description":"CRON_SECRET not configured (fail closed)"}}}},"/api/instagram":{"get":{"tags":["instagram"],"summary":"Connected Instagram accounts","description":"The org's connected Instagram professional accounts and their connection health. The ACCESS TOKEN IS NEVER RETURNED and has no 'shown once' escape hatch — it is excluded at the SELECT, not stripped afterwards. `status` is `active` | `needs_reconnect` | `revoked`; anything other than `active` means automations are paused and `statusMessage` says why in plain language.","responses":{"200":{"description":"Connected accounts"},"401":{"description":"Unauthenticated"}}}},"/api/instagram/meta":{"get":{"tags":["instagram"],"summary":"Can we connect, and has Meta approved us?","description":"`{ appConfigured, appMode, authUrl }` — the honesty endpoint the creator UI reads before it renders anything. `appMode` (`development` | `live`) comes from META_APP_MODE and DEFAULTS TO `development`: a missing env var must never be the reason a creator is told they are live when they are not. While it is `development`, Meta only lets the app act on accounts with a role on it, and the dashboard says so above the connect button. `appConfigured` is false unless META_APP_ID, META_APP_SECRET and META_TOKEN_KEY are all provisioned — without the vault key no token can be sealed, so the flow refuses to start rather than stranding a creator mid-OAuth. `authUrl` (Instagram's authorize URL, with the three `instagram_business_*` scopes and an HMAC `state`) is null whenever the app is unconfigured or the plan does not include DM automations. No secret is in the response: `client_id` is public and `state` is an HMAC.","responses":{"200":{"description":"Connect info"},"401":{"description":"Unauthenticated"}}}},"/api/instagram/connect":{"post":{"tags":["instagram"],"summary":"Finish the OAuth round trip","description":"Body `{ code, state }`. The creator's browser lands on the web app's callback page (session cookie intact) and POSTs the authorization code here; the server exchanges it with the app secret across Meta's THREE token hosts (api.instagram.com → short-lived 1h, graph.instagram.com/access_token → long-lived 60d, graph.instagram.com/me → the handle). The resulting token is AES-256-GCM sealed with `igUserId` as AAD before it touches the database and is never returned. `state` is an HMAC of the org id, so a code minted for one org cannot be redeemed by another. Nothing is written on any failure — there is no half-connected account. Reconnecting revives the SAME row this org already had, so existing automations and their history survive. The unique index on ig_user_id is PARTIAL (`where status <> 'revoked'`): another org holding the account LIVE is a 409, but a DISCONNECTED one no longer blocks anybody — without that, one org connecting an account locked every other org out of it permanently, even after a disconnect and even when the creator legitimately changed workspaces. A revoked row keeps its original org and history; the new org gets its own. NOTE: there is no published Meta app yet, so the happy path is exercised by unit tests against the documented contract with a mocked fetch, not against Meta.","responses":{"201":{"description":"Connected (the account row — never the token)"},"400":{"description":"Missing code"},"403":{"description":"Flag off, not entitled, or a forged `state`"},"409":{"description":"That Instagram account is connected to another org AND still live there"},"502":{"description":"Meta refused the exchange (a status + numeric code only, never a body)"},"503":{"description":"META_APP_ID / META_APP_SECRET / META_TOKEN_KEY not provisioned (fail closed)"}}}},"/api/instagram/accounts/{id}":{"delete":{"tags":["instagram"],"summary":"Disconnect an Instagram account","description":"Revokes our access. NOT a delete: `status` flips to `revoked` and the sealed token is replaced with an empty string, so the credential is gone while every automation and event row referencing the account survives. Allowed on ANY tier — a creator must always be able to revoke our access to their Instagram, and putting that behind a paywall would be indefensible. Disconnecting is also NOT a permanent global lockout: the ig_user_id unique index excludes revoked rows, so the same Instagram account can afterwards be connected by a different org (a creator moving workspaces) while this org keeps its own row and history.","responses":{"200":{"description":"Disconnected"},"401":{"description":"Unauthenticated"},"404":{"description":"Not found"}}}},"/api/instagram/automations":{"post":{"tags":["instagram"],"summary":"Create a DM automation","description":"The 90% case as one request: 'someone comments LINK on my post, DM them my link.' NOTE WHAT IS ABSENT — there is no `url` field. The DM's link is a FOREIGN KEY (`linkId`) into the creator's links, never a typed destination: a Zippy link springs the real native app out of Instagram's in-app browser and reports the app-open, a raw URL does neither. A link typed into `dmText` is rejected with `raw_link_in_dm_text` here and again at the send choke point — and A SCHEME IS NOT REQUIRED TO COUNT AS ONE: Instagram auto-links bare domains in DM text, so `mybrand.com/sale`, `www.shop.io/drop` and `bit.ly/x` are refused exactly like `https://…`. Ordinary prose is not (\"check it out at 3 p.m.\" is fine); the rejection names the offending substring in `offending`. `trigger` is re-cleaned server-side with `cleanDmTrigger` (an unknown `type`/`match` kills the rule); an EMPTY keyword list is a real configuration ('DM everyone who comments'), not a validation failure. Flag-gated then Legend-gated. Capped at 25 rules per account.","responses":{"201":{"description":"The created rule"},"400":{"description":"bad_trigger · missing_dm_text · raw_link_in_dm_text · unknown_account · too_many_automations. A `raw_link_in_dm_text` also carries `offending`: the exact substring we refused."},"403":{"description":"Flag off, or not on Legend"}}},"get":{"tags":["instagram"],"summary":"DM automation rules","description":"Every rule in the org, oldest first — the order the engine evaluates them in, which decides who wins the single private reply Instagram allows per comment. NOT tier-gated: `dmAutomations` (Legend) gates creating, editing and FIRING a rule, never reading one, so a downgraded org can still see, pause and delete its automations.","responses":{"200":{"description":"Automation rules"},"401":{"description":"Unauthenticated"}}}},"/api/instagram/automations/{id}":{"patch":{"tags":["instagram"],"summary":"Edit a rule — or pause it on any tier","description":"THE DOWNGRADE RULE, ENCODED: a body that ONLY pauses (`{\"enabled\": false}` and nothing else) is accepted on every tier; any other edit requires the Legend entitlement. An entitlement gates USING a feature, never removing it — a downgraded creator keeps every rule they wrote and must always be able to STOP them, because automations left running with no off switch would be strictly worse than deleted ones. A paused rule stays configured and re-enableable; it simply never fires.","responses":{"200":{"description":"The updated rule"},"400":{"description":"bad_trigger · missing_dm_text · raw_link_in_dm_text (with `offending`, the substring refused)"},"403":{"description":"Flag off, or an edit beyond pausing without Legend"},"404":{"description":"Not found"}}},"delete":{"tags":["instagram"],"summary":"Delete a rule","description":"NOT tier-gated — tidying up is never gated. The rule's `dm_events` history goes with it (it has no meaning without the rule that wrote it), by a real `ON DELETE CASCADE` — this used to say the same thing while the FK was actually `ON DELETE SET NULL`, which both left unreachable orphan rows behind and silently broke their idempotency: Postgres unique indexes do not dedupe NULLs, so `UNIQUE(automation_id, source_id)` stopped protecting a row the moment its automation_id went NULL. The PERMANENCE INVARIANT is untouched: a Zippy link that was DM'd to somebody keeps redirecting forever regardless of whether the automation that sent it still exists.","responses":{"200":{"description":"Deleted"},"401":{"description":"Unauthenticated"},"404":{"description":"Not found"}}}},"/api/instagram/automations/{id}/stats":{"get":{"tags":["instagram"],"summary":"Per-automation performance — DMs sent AND app opens","description":"`{ sent, failed, skipped, recipients, link }`. `link` is the app-open outcome of the Zippy link this automation's DM carries: `{ shortUrl, clicks, opened, appOpenRate }` — the number no other DM tool can produce, because their links open in Instagram's in-app browser and report nothing. It is the SAME Workers Analytics Engine data and the same 90-day window as GET /api/links/{id}/stats, not a parallel funnel. `link` is null when the rule carries no link, when the link is gone, or when analytics is unconfigured (honest empty, never a fabricated zero). `skipped` includes rows still `queued` — an unresolved attempt is never counted as sent.","responses":{"200":{"description":"Automation stats"},"401":{"description":"Unauthenticated"},"404":{"description":"No such automation in this org"}}}},"/api/instagram/automations/{id}/events":{"get":{"tags":["instagram"],"summary":"Firing history, including everything that did NOT send","description":"The 100 most recent firings, newest first. FAILURE HONESTY: a send that did not happen is a row here, never a swallowed error — `status` is `queued` | `sent` | `failed` | `skipped`, `error` is the machine token, and `reason` is the same thing in creator language (e.g. `outside_24h_window` → \"Instagram only lets you reply for 24 hours after someone messages you…\"). Meta's own error codes have no plain-language entry and come back with `reason: null` rather than being rendered raw, because Meta error bodies quote request parameters back. Inbound message text is never stored, so it is not here.","responses":{"200":{"description":"Firing history"},"401":{"description":"Unauthenticated"}}}},"/api/broadcasts":{"get":{"tags":["Broadcasts"],"summary":"List broadcasts","description":"The org's broadcasts, newest first (100 max), each with its status. Flag-gated only — a downgraded creator can still READ their send history, the same law that lets them still delete a segment.","responses":{"200":{"description":"The broadcasts"},"401":{"description":"Unauthenticated"}}},"post":{"tags":["Broadcasts"],"summary":"Compose a broadcast","description":"NOTE WHAT IS ABSENT: there is no `to`, `recipients` or `emails` field, on this or any other broadcast endpoint. Recipients are DERIVED server-side from the creator's own consented contacts — which is what makes 'a contact without consent can never receive a broadcast' a property of the API's shape rather than a check someone could forget. There is also no `url` field: the CTA is `linkId`, a foreign key into the creator's own links, because a Zippy link springs the real native app and reports its app-open while a raw URL does neither. A raw destination in `subject` or `body` is refused with `raw_link_in_broadcast` (a scheme is not required — bare hosts like `mybrand.com/sale` are refused too) and the response names the offending substring. Flag-gated then Hero-gated. Capped at 200 broadcasts per org.","responses":{"201":{"description":"The created draft"},"400":{"description":"missing_subject · missing_body · raw_link_in_broadcast (with `offending`) · unknown_link"},"403":{"description":"Flag off, or not on Hero+"},"409":{"description":"Broadcast limit reached"}}}},"/api/broadcasts/{id}":{"patch":{"tags":["Broadcasts"],"summary":"Edit a draft broadcast","description":"DRAFTS ONLY — 409 `already_sending` otherwise. Once recipients are materialized, half the list may already hold the old copy; editing then would make the send history a lie about what was actually delivered.","responses":{"200":{"description":"The updated draft"},"400":{"description":"Same validation as compose"},"404":{"description":"Not found"},"409":{"description":"already_sending"}}},"delete":{"tags":["Broadcasts"],"summary":"Delete a broadcast","description":"Allowed on ANY tier so a downgraded creator can still tidy up (the folders/segments precedent). Deletes the recipient rows with it; it does not un-send anything already delivered.","responses":{"200":{"description":"Deleted"},"404":{"description":"Not found"}}}},"/api/broadcasts/preview":{"post":{"tags":["Broadcasts"],"summary":"Preview the real recipient count","description":"The number the compose screen shows before anyone commits, computed with the SAME predicate the send materializes with — so the preview cannot promise people the send will not honour. `consented` is the count AFTER consent and unsubscribe are applied; `contactsOnFile` is every contact, so the gap between them is visible instead of mysterious. Pass `segmentId` for a saved segment or `filters` for an unsaved draft; pass neither for the whole list. Segments are evaluated by the EXISTING segment evaluation (`buildSegmentPlan` + `segmentCounts`) — there is no second audience query layer. Ceiling, stated: a segment filters click/app-open rows, which carry no person identity, so the join to contacts is BY LINK ('the people who joined your list from the links this segment matched'), not a per-person guarantee that each recipient personally app-opened.","responses":{"200":{"description":"{ consented, contactsOnFile, segmentLinks, capped }"},"400":{"description":"unknown_segment · segment_unavailable (WAE unreachable — we refuse rather than guess) · an invalid filter combination"},"403":{"description":"Flag off, or not on Hero+"}}}},"/api/broadcasts/{id}/send":{"post":{"tags":["Broadcasts"],"summary":"Start sending","description":"Materializes one recipient row per ELIGIBLE contact and returns immediately; delivery happens on the `/internal/broadcast-tick` cron, so this never blocks on the mail provider. IDEMPOTENT: re-POST after a crash, a timeout or a double-click and the same INSERT … SELECT runs with ON CONFLICT DO NOTHING against UNIQUE(broadcast_id, contact_id) — already-materialized contacts get no second row and therefore no second email. That is why resuming is safe and why there is no separate resume endpoint to get wrong. Consent and unsubscribe are enforced HERE (only eligible contacts produce rows) and AGAIN inside the tick against each contact's current row, so an unsubscribe landing mid-flight is honoured on the very next recipient. If the segment cannot be evaluated the send is REFUSED (`segment_unavailable`) — falling back to 'everyone' would turn an outage into a mailing that cannot be recalled.","responses":{"200":{"description":"{ status: 'sending', recipients, queued, capped }"},"400":{"description":"raw_link_in_broadcast · unknown_segment · segment_unavailable"},"403":{"description":"Flag off, or not on Hero+"},"404":{"description":"Not found"},"409":{"description":"already_sent"}}}},"/api/broadcasts/{id}/stats":{"get":{"tags":["Broadcasts"],"summary":"Sent / failed / skipped + app-open rate","description":"Per-status tallies plus every skip reason in creator language — a send that did not happen is always visible, never swallowed. HONESTY: `sent` means the email provider ACCEPTED it; we do not consume delivery webhooks, so the response carries `deliveryTracked: false` instead of relabelling accepted mail as delivered. `appOpens` is the payoff — because the CTA is a Zippy link, a broadcast reports its own app-open outcome (sampled, flagged as such), which is a number no other email tool can produce.","responses":{"200":{"description":"{ status, counts, skipReasons, deliveryTracked, appOpens }"},"403":{"description":"Flag off, or not on Hero+"},"404":{"description":"Not found"}}}},"/api/broadcasts/{id}/send-dm":{"post":{"tags":["Broadcasts"],"summary":"DM this segment on Instagram (always refused)","description":"ALWAYS 422 `dm_broadcast_unverified`. Documented rather than absent so the answer is reviewable instead of looking like a gap someone should fill. Meta's 24-hour window governs ONGOING conversations — your DM automations already reply inside it. Re-engaging people outside it requires message tags, one-time notifications or paid messaging, and this codebase has audited that surface and found the encoding unverified for the Instagram Login flow we hold tokens under (even `human_agent` is deliberately unimplemented for the same reason). Under `instagram.ts`'s own rule — where the rule is unverified, we do not send — this fails closed. The cost of being wrong is a creator's account getting restricted, which we cannot give back.","responses":{"422":{"description":"dm_broadcast_unverified, with the full reason"}}}},"/api/public/unsubscribe/{id}":{"post":{"tags":["Broadcasts"],"summary":"One-click unsubscribe (public)","description":"NO AUTH, NO TIER, NO FLAG — withdrawing consent is never paywalled or gated, exactly like erasure. GET (the footer link) and POST (RFC 8058 one-click, fired by the mail client on the user's behalf) do the same thing. Every broadcast carries both `List-Unsubscribe` and `List-Unsubscribe-Post` headers pointing here. Idempotent, and an unknown id also reports success so the endpoint never becomes an oracle for which contact ids exist. Takes effect immediately: the tick re-reads consent per recipient, so even an in-flight broadcast skips them.","responses":{"200":{"description":"An HTML confirmation page"}}}},"/v1/me":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Whoami — verify a key and read the org's entitlement","description":"Cheapest possible call that proves a key is live. Returns `{ orgId, tier, features, rateLimit }`. Use it as the credential test in an integration: a 200 means the key is valid, unrevoked, and the org still holds the `api` feature.","responses":{"200":{"description":"The key's org, tier and resolved feature flags","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Me"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/links":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"List links","description":"Newest-first. Archived links are excluded. Filter with `?tag=<name>` and `?folder=<uuid>` (`?folder=none` for unfiled). Each row: id, slug, hostname, destination, platformKey, branded, readOnly, utm, routing, ab, sched, hasPassword (the hash is NEVER returned), tags, folderId, pixelIds, captureEmails, createdAt, updatedAt.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size (max 200, default 50). Sending `limit` or `offset` switches the response to a page; omitting both returns the full list."},"required":false,"description":"Page size (max 200, default 50). Sending `limit` or `offset` switches the response to a page; omitting both returns the full list.","name":"limit","in":"query"},{"schema":{"type":"integer","nullable":true,"minimum":0,"description":"Rows to skip. Page until fewer than `limit` rows come back."},"required":false,"description":"Rows to skip. Page until fewer than `limit` rows come back.","name":"offset","in":"query"},{"schema":{"type":"string","description":"Only links carrying this tag (lower-cased)."},"required":false,"description":"Only links carrying this tag (lower-cased).","name":"tag","in":"query"},{"schema":{"type":"string","description":"Folder uuid, or `none` for unfiled links."},"required":false,"description":"Folder uuid, or `none` for unfiled links.","name":"folder","in":"query"}],"responses":{"200":{"description":"Array of links","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/V1Link"}}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}},"post":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Create a link","description":"Creates a short link. `url` is required; everything else is optional and independently entitlement-gated — an ungated field returns 403 with the feature's code rather than being silently dropped. `slug` is generated when omitted. `hostname` must be one of the org's verified custom domains (Legend). PERMANENCE: a created link never expires and has no click cap.","requestBody":{"description":"The link to create","required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","maxLength":4096},"slug":{"type":"string"},"hostname":{"type":"string","maxLength":255},"utm":{"type":"object","properties":{"source":{"type":"string","maxLength":200},"medium":{"type":"string","maxLength":200},"campaign":{"type":"string","maxLength":200},"term":{"type":"string","maxLength":200},"content":{"type":"string","maxLength":200}}},"routing":{"type":"object","properties":{"ios":{"type":"string","maxLength":4096},"android":{"type":"string","maxLength":4096},"desktop":{"type":"string","maxLength":4096},"geo":{"type":"object","additionalProperties":{"type":"string","maxLength":4096}}}},"ab":{"type":"array","items":{"type":"object","properties":{"u":{"type":"string","maxLength":4096},"w":{"type":"number","minimum":0,"exclusiveMinimum":true}},"required":["u","w"]},"minItems":2,"maxItems":4},"sched":{"type":"array","items":{"type":"object","properties":{"from":{"type":"integer"},"url":{"type":"string","maxLength":4096}},"required":["from","url"]},"maxItems":10},"password":{"type":"string","nullable":true,"maxLength":200},"tags":{"type":"array","items":{"type":"string"},"maxItems":20},"folderId":{"type":"string","nullable":true,"format":"uuid"},"pixelIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":5},"captureEmails":{"type":"boolean"}},"required":["url"]}}}},"responses":{"201":{"description":"The created link","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Link"}}}},"400":{"description":"Invalid body, bad URL, or a slug that is taken","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"An entitlement gate: `link_cap`, `custom_slug`, `custom_domain`, `ab_testing`, `scheduled_links`, `link_routing`, `password_links`, `folders`, `pixel_injection`, `audience_capture`, or `feature_disabled` for a dark-launched field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/links/bulk":{"post":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Create up to 100 links in one call","description":"Legend (`bulk_create`). Body: `{ links: [ …up to 100 create bodies… ] }` (no `hostname` per item). Always 207: each element of `results` is that link's own success body or error — a partial failure never rolls the batch back.","responses":{"207":{"description":"Per-item results, in request order","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1BulkResult"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"`bulk_create` — Legend only","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/links/{id}":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Get one link","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"The link","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Link"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown or archived","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}},"patch":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Update a link (living links)","description":"Re-point the destination and/or rename the slug and/or edit routing, ab, sched, utm, password, tags, folderId, pixelIds, captureEmails. At least one field required. `null` clears a nullable field; an array/object REPLACES it wholesale. `routing` and `ab` are mutually exclusive. A read-only link (over the plan's link cap after a downgrade) rejects edits with 403 `read_only` — it KEEPS REDIRECTING; downgrades never break a live link.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"requestBody":{"description":"Fields to change","required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","maxLength":4096},"slug":{"type":"string"},"utm":{"type":"object","nullable":true,"properties":{"source":{"type":"string","maxLength":200},"medium":{"type":"string","maxLength":200},"campaign":{"type":"string","maxLength":200},"term":{"type":"string","maxLength":200},"content":{"type":"string","maxLength":200}}},"routing":{"type":"object","nullable":true,"properties":{"ios":{"type":"string","maxLength":4096},"android":{"type":"string","maxLength":4096},"desktop":{"type":"string","maxLength":4096},"geo":{"type":"object","additionalProperties":{"type":"string","maxLength":4096}}}},"ab":{"type":"array","nullable":true,"items":{"type":"object","properties":{"u":{"type":"string","maxLength":4096},"w":{"type":"number","minimum":0,"exclusiveMinimum":true}},"required":["u","w"]},"minItems":2,"maxItems":4},"sched":{"type":"array","nullable":true,"items":{"type":"object","properties":{"from":{"type":"integer"},"url":{"type":"string","maxLength":4096}},"required":["from","url"]},"maxItems":10},"password":{"type":"string","nullable":true,"maxLength":200},"tags":{"type":"array","items":{"type":"string"},"maxItems":20},"folderId":{"type":"string","nullable":true,"format":"uuid"},"pixelIds":{"type":"array","nullable":true,"items":{"type":"string","format":"uuid"},"maxItems":5},"captureEmails":{"type":"boolean"}}}}}},"responses":{"200":{"description":"The updated link","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Link"}}}},"400":{"description":"Invalid body, bad URL, or no field supplied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"`read_only`, a per-field entitlement code, or `feature_disabled`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown or archived","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"409":{"description":"`slug_taken`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/links/{id}/archive":{"post":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Archive a link","description":"Removes the link from the dashboard's list and frees a slot against the plan's link cap. THE LINK KEEPS REDIRECTING — archive is not delete, and Zippy has no endpoint that stops a published link from resolving.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"Archived","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Archived"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/links/{id}/qr":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"QR code for the link's short URL (SVG or PNG)","description":"Always renders — custom styling params (`dark`, `light`, `logo`, `frame`, `caption`, `volt`) are ignored rather than rejected when the org isn't entitled, so a QR never 403s. Cacheable: the short URL is immutable.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"image/svg+xml or image/png","content":{"image/svg+xml":{"schema":{"type":"string"}},"image/png":{"schema":{"type":"string"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/links/{id}/og":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Effective social preview (OG) for the link","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"Title/description/image + their source","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1OgPreview"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}},"patch":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Override the social preview, per field","description":"Per-field override; `null` on a field restores the scraped default.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"The saved preview","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1OgPreview"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/links/{id}/og/refresh":{"post":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Re-scrape the destination for fresh preview defaults","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"The refreshed preview","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1OgPreview"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/links/{id}/stats":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"APP-OPEN OUTCOMES for a link — did the tap reach the real app?","description":"The metric no other link tool can report, and the one an automation should branch on.\n\nReturns `{ configured, total, appOpenRate, outcomes: { opened, browser, broken } }` over a fixed 90-day window. `opened` = the destination app actually came to the foreground; `browser` = the tap landed in an in-app or mobile browser instead; `broken` = the deep link failed.\n\nHONESTY CONTRACT — read before building on this: these counts come from Cloudflare Analytics Engine and are SAMPLED. Treat `appOpenRate` as a trend, not an exact ratio, and do not present false precision. `appOpenRate` is `null` (never `0`) when there is no outcome data yet — an automation must branch on `null` as 'no data', not as 'nobody opened it'. `configured:false` means outcome collection is not wired up upstream and every number is a placeholder zero.\n\nSidekick tier receives `total` only, with `appOpenRate: null` and zeroed `outcomes`; the split needs `fullAnalytics` (Hero+).","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"Outcome rollup","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1LinkStats"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/links/{id}/clicks":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Per-outcome event feed for a link (newest first, paged)","description":"Hero+ (`fullAnalytics`); other tiers get an empty list plus an `upgrade` string rather than a 403. Each row: `{ id, at (ISO instant), outcome, sourceApp, country, city, device }`. 90-day window. SAMPLED — this is a representative feed, not an audit log; never reconcile it against a billing total.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size (max 200, default 50). Sending `limit` or `offset` switches the response to a page; omitting both returns the full list."},"required":false,"description":"Page size (max 200, default 50). Sending `limit` or `offset` switches the response to a page; omitting both returns the full list.","name":"limit","in":"query"},{"schema":{"type":"integer","nullable":true,"minimum":0,"description":"Rows to skip. Page until fewer than `limit` rows come back."},"required":false,"description":"Rows to skip. Page until fewer than `limit` rows come back.","name":"offset","in":"query"}],"responses":{"200":{"description":"`{ configured, clicks: [] }`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Clicks"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/links/{id}/ab-stats":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Per-variant A/B results, including each variant's app-open rate","description":"Hero+ (`abTesting`). Returns `{ configured, sampled: true, window, variants: [{ url, weight, variant, clicks, outcomes, opened, appOpenRate }] }`. Every configured variant is always present (a variant with no traffic reads as 0, it does not vanish). `sampled: true` is load-bearing: clicks and outcomes come from two independently sampled datasets, so this is a TREND — pick a winner on a clear gap, not on a decimal.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"Per-variant report","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1AbStats"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/links/{id}/receipt":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Shareable app-open receipt card (SVG)","description":"Any tier, gated on the app-open-receipt flag. 409 `no_data` when the link has no taps yet — it never renders invented numbers.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"image/svg+xml","content":{"image/svg+xml":{"schema":{"type":"string"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"`feature_disabled`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"409":{"description":"`no_data` — no taps yet","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/folders":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"List folders with a live link count","responses":{"200":{"description":"Array of `{ id, name, linkCount, createdAt }`","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/V1Folder"}}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"`feature_disabled`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}},"post":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Create a folder","description":"Hero+ (`folders`). Body `{ name }` (1–60 chars).","responses":{"201":{"description":"The folder","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Folder"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"`folders` or `feature_disabled`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/folders/{id}":{"patch":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Rename a folder","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"The folder","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1FolderRenamed"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"Tier or flag gate","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}},"delete":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Delete a folder","description":"Any tier (cleanup after a downgrade must always be possible). Its links are UNFILED, never deleted.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Deleted"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/pixels":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"List retargeting pixels","responses":{"200":{"description":"Array of `{ id, type, pixelId, name, createdAt }`","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/V1Pixel"}}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"`feature_disabled`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}},"post":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Register a retargeting pixel","description":"Hero+ (`pixelInjection`). Body `{ type, pixelId, name }`. Max 5 attach per link.","responses":{"201":{"description":"The pixel","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Pixel"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"`pixel_injection` or `feature_disabled`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/pixels/{id}":{"delete":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Delete a pixel","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Deleted"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/bio":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"The org's link-in-bio page","responses":{"200":{"description":"`{ page: BioPage | null }`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1BioEnvelope"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"`feature_disabled`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}},"put":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Claim or update the bio page","description":"Upsert of `{ handle, title, bio, avatarUrl, theme, published }`. The handle is lower-cased, checked against the reserved list and unique case-insensitively. Omitting `theme` preserves the stored one; setting it needs `bioCustomTheme` (Hero+).","responses":{"200":{"description":"The saved page","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1BioSaved"}}}},"400":{"description":"Invalid body / `invalid_handle`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"`link_in_bio`, `bio_custom_theme` or `feature_disabled`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"409":{"description":"`handle_taken`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}},"delete":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Delete the bio page","description":"Ungated. The blocks' links keep redirecting.","responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Deleted"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/bio/blocks":{"put":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Replace the ordered block list","description":"One atomic write of the whole array — the array index IS the page order, so this is create, update, reorder and delete in a single call. Every tappable block carries a `linkId` the org owns; a block never carries a raw URL. An unknown linkId rejects the WHOLE write (nothing is partially applied).","responses":{"200":{"description":"The saved page","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1BioSaved"}}}},"400":{"description":"`invalid_blocks` or `unknown_link` — nothing was written","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"Tier or flag gate","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"No page yet","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/bio/stats":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Per-block app-open rate","description":"`{ configured, sampled: true, window, blocks: [{ linkId, clicks, outcomes, opened, appOpenRate }] }`, keyed by linkId so a client joins it onto the block it already holds. SAMPLED; `appOpenRate` is `null` with no data (render '—', never 0%). Hero+ — other tiers get an `upgrade` string and an empty list.","responses":{"200":{"description":"Per-block stats","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1BioStats"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/audience":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"List captured contacts (paged)","description":"Hero+ (`audienceCapture`) + the audience-capture flag. Returns `{ count, contacts: [{ id, email, sourceSlug, country, createdAt }] }` newest-first. `count` is the org total, not the page size — page with `limit`/`offset` until fewer than `limit` rows return.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size (max 200, default 50). Sending `limit` or `offset` switches the response to a page; omitting both returns the full list."},"required":false,"description":"Page size (max 200, default 50). Sending `limit` or `offset` switches the response to a page; omitting both returns the full list.","name":"limit","in":"query"},{"schema":{"type":"integer","nullable":true,"minimum":0,"description":"Rows to skip. Page until fewer than `limit` rows come back."},"required":false,"description":"Rows to skip. Page until fewer than `limit` rows come back.","name":"offset","in":"query"}],"responses":{"200":{"description":"Contacts + total count","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Audience"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"`audience_capture` or `feature_disabled`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/audience/{id}":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"One contact, in full","description":"Hero+ (`audienceCapture`) + the audience-capture flag. The contact plus the consent wording on file, whether they unsubscribed, the link they joined from, and every broadcast addressed to them (newest first, capped at 50) with a creator-language reason for any that did not send. Per-person link taps, app-opens and DM history are NOT returned — click rows carry no identity and Instagram-scoped ids never resolve to an email address, so those questions have no honest per-person answer.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"The contact","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1ContactDetail"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"`audience_capture` or `feature_disabled`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}},"delete":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Erase one contact","description":"ANY tier, never paywalled — erasure is a GDPR data-subject right and a downgraded creator must still be able to honour a deletion request.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Deleted"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/audience/export.csv":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"The org's contacts as CSV","description":"Formula-neutralized cells (a captured email is untrusted user text).","responses":{"200":{"description":"text/csv","content":{"text/csv":{"schema":{"type":"string"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"`audience_capture` or `feature_disabled`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/webhooks":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"List webhook subscriptions","description":"PREFER THIS OVER POLLING. Subscribe once and Zippy pushes link and app-open events to your URL; an idle automation should make zero requests to this API.","responses":{"200":{"description":"Array of subscriptions (the signing secret is never re-returned)","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/V1Webhook"}}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"Legend gate","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}},"post":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Create a webhook subscription","description":"Legend. Body `{ url, events }`. The signing secret is returned ONCE at creation — store it, it cannot be read back. Deliveries are signed; verify the signature before trusting a payload.","responses":{"201":{"description":"The subscription + its one-time secret","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1WebhookCreated"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"Legend gate","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/webhooks/{id}":{"patch":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Update a subscription (url, events, active)","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"The subscription","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Webhook"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}},"delete":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Delete a subscription","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource id (uuid)."},"required":true,"description":"Resource id (uuid).","name":"id","in":"path"}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Deleted"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"404":{"description":"Unknown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/analytics":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Org-wide click analytics + outcome rollups","description":"Returns `{ configured, days, tier, total, perLink }` on every tier. Hero+ additionally returns `countries`, `devices`, `platforms`, `referrers`, `timeseries`, `hourly`, `outcomes` (org-wide opened/browser/broken) and `platformOutcomes` (the 'Instagram: 89% real-app opens' split) — Sidekick gets an `upgrade` string instead.\n\nSAMPLED (Cloudflare Analytics Engine). Every number here is an estimate from a sampled dataset: report whole numbers, never decimals of a percent, and always surface that it is sampled. `configured:false` means the upstream dataset is not wired up — the zeros are placeholders, NOT a real 'no traffic' answer, and must not be charted as zero.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":90,"description":"Look-back window in days (1–90, default 30)."},"required":false,"description":"Look-back window in days (1–90, default 30).","name":"days","in":"query"}],"responses":{"200":{"description":"Analytics payload","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Analytics"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"502":{"description":"Upstream analytics unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}},"/v1/analytics/export":{"get":{"tags":["v1"],"security":[{"apiKey":[]}],"summary":"Click totals as CSV","description":"Legend (`analyticsExport`). `slug,clicks` over the requested window.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":90,"description":"Look-back window in days (1–90, default 30)."},"required":false,"description":"Look-back window in days (1–90, default 30).","name":"days","in":"query"}],"responses":{"200":{"description":"text/csv","content":{"text/csv":{"schema":{"type":"string"}}}},"401":{"description":"Missing, malformed, unknown or REVOKED key — or the org no longer holds the `api` feature (entitlement is re-checked per request).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"403":{"description":"`analytics_export` — Legend only","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"413":{"description":"Request body over 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"429":{"description":"Rate limited. 120 requests per 60s per key. Honour the `Retry-After` header (seconds); `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds) are on every response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}},"503":{"description":"Session/store lookup temporarily unavailable — retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/V1Error"}}}}}}}}}