diff --git a/analytics/custom-dashboards.mdx b/analytics/custom-dashboards.mdx
new file mode 100644
index 0000000..310cffe
--- /dev/null
+++ b/analytics/custom-dashboards.mdx
@@ -0,0 +1,144 @@
+---
+title: "Custom Dashboards"
+description: "Compose the Analytics board: add, resize, and build your own widgets"
+icon: "layout-grid"
+---
+
+The Analytics page (`/dashboard/analytics`) is a widget grid you compose yourself. Rearrange the defaults, build custom charts against any dimension, and pin individual links to it. The layout is saved to your account, so it follows you across devices, and you can export, import, or reset it at any time.
+
+
+
+
+
+
+## The board
+
+The toolbar holds the date-range picker (the same one as the [Links page](/links/managing-links#date-ranges), including natural-language ranges), dimension filter chips, **Edit layout**, and refresh with auto-refresh.
+
+The default layout:
+
+| Widget | Shows |
+|---|---|
+| Stat tiles | Total clicks, unique visitors, unique rate, clicks per visitor, each with an explainer tooltip |
+| Clicks over time | Time series with a total, unique, or both toggle |
+| Countries | A world map of click origins |
+| Cities | Ranked city bars |
+| Top links | Your most-clicked links |
+| Referrers | Where clicks came from, with favicons |
+| Browsers and Operating systems | Client breakdowns |
+
+Every widget has a chart or table view toggle and an expand button that opens it fullscreen.
+
+
+
+
+
+
+## Cross-filtering
+
+Click any value in any widget to filter the whole board by it. Clicking a referrer bar, a country on the map, or a browser slice adds a filter chip to the toolbar, such as `Referrer: twitter.com`, and every widget refilters to that slice. Stack filters across dimensions (Links, Referrer, Country, Browser, OS, City), and clear them from the chips.
+
+
+
+
+
+
+## Edit the layout
+
+
+
+ Click **Edit layout** in the toolbar. The board switches to a dotted grid; widgets get drag handles, resize corners, and a remove button.
+
+
+
+
+
+
+
+ **Add widget** lists every widget type with a checkbox; removed defaults can be re-added the same way.
+
+
+
+
+
+
+
+ Drag widgets anywhere on the grid and resize from the corners. Widgets adapt their density to their size, so a small tile and a full-width chart of the same data both stay readable.
+
+
+
+
+
+
+
+ Click **Done** to save. The layout is stored on your account and applied on every device you sign in from.
+
+
+
+Edit mode has full undo and redo: `⌘Z` and `⌘⇧Z` step through every add, move, resize, and delete in the session.
+
+## Custom charts
+
+**Add widget** also offers a custom chart: pick what to plot, how to draw it, and what slice of your traffic it covers, with a live preview as you configure.
+
+
+
+
+
+
+| Setting | Options |
+|---|---|
+| Title | Anything you like |
+| X axis | None, Time, Links, Referrers, Countries, Cities, Browsers, OS |
+| Y axis | Total clicks, unique clicks, or both |
+| Chart type | Area for time series; Bars, Columns, Donut, Pie, Treemap, Radial, Radar, Bubbles, Scatter, or Table for dimensions |
+| Scope | Filter the widget to specific links, referrers, countries, cities, browsers, or OSes |
+| Ink | 12 accent colors |
+
+
+
+
+
+
+
+ Scope is what makes custom charts composable: a board can hold "campaign links only, by country" next to "all traffic, by referrer" and both respect the global date range and filter chips.
+
+
+### The widget inspector
+
+Selecting a custom widget in edit mode brings up a floating inspector: rename it, switch chart type, toggle total or unique, change the ink, adjust the scope, **Duplicate**, **Reset chart**, or **Delete**, all without reopening the builder.
+
+
+
+
+
+
+Duplicate is the fast way to build variants: copy a configured widget, then change one setting on the copy.
+
+## Pin a link to the board
+
+From the Links page, **Pin to dashboard** in a row's actions menu adds that link's clicks-over-time widget to the board. The widget carries a scope chip with the link's alias and behaves like any other widget from there: move it, resize it, or remove it in edit mode.
+
+
+
+
+
+
+For a deep dive on a single link, its [stats page](/analytics/link-stats) goes further than a pinned widget.
+
+## Export, import, and reset
+
+The overflow menu in edit mode manages the layout as a document:
+
+| Action | Does |
+|---|---|
+| Export layout | Downloads the layout as a file |
+| Import layout | Restores a layout from a file, replacing the current one |
+| Reset layout | Discards your customizations and returns to the default board |
+
+
+
+
+
+
+Export and import make layouts portable: keep a backup before a big rearrangement, or hand a colleague your board. Reset is safe to reach for; the default layout is always one click away.
diff --git a/analytics/dimensions.mdx b/analytics/dimensions.mdx
new file mode 100644
index 0000000..4015174
--- /dev/null
+++ b/analytics/dimensions.mdx
@@ -0,0 +1,101 @@
+---
+title: "Dimensions & Filtering"
+description: "Group and filter click analytics by time, device, geography, referrer, and campaign tags"
+icon: "layers"
+---
+
+Every analytics query slices clicks along one or more dimensions. The same dimensions power the dashboard widgets and the `GET /api/v1/stats` API.
+
+## All dimensions
+
+| Dimension | Meaning |
+| --- | --- |
+| `time` | Clicks bucketed by hour, day, week, or month, auto-selected from the range you query |
+| `browser` | Browser family, e.g. Chrome, Firefox |
+| `os` | Operating system family, e.g. Windows, iOS |
+| `device` | `mobile`, `tablet`, `desktop`, or `unknown` |
+| `country` | Visitor country, from IP geolocation |
+| `city` | Visitor city, from IP geolocation |
+| `referrer` | Domain the visitor came from; `Direct` when there was no referrer |
+| `short_code` | The short link that was clicked; useful for account-wide breakdowns |
+| `utm_source` | `utm_source` tag on the clicked short link; `(none)` when untagged |
+| `utm_medium` | `utm_medium` tag; `(none)` when untagged |
+| `utm_campaign` | `utm_campaign` tag; `(none)` when untagged |
+
+Metrics are `clicks` and `unique_clicks` (deduplicated by visitor IP). Both are computed for every dimension you group by.
+
+## Grouping and filtering
+
+In the dashboard, each widget is a grouped view: a countries widget groups by `country`, a devices widget by `device`, and so on. Filters narrow every widget at once.
+
+In the API, pass `group_by` as a comma-separated list. Filters can be sent two ways: as individual query parameters (`browser=Chrome,Firefox`) or as a single JSON `filters` object. Both accept multiple values, combined with OR within a dimension and AND across dimensions.
+
+
+
+```bash curl
+# Mobile Chrome clicks from the US and Germany, grouped by day and campaign
+curl -G "https://spoo.me/api/v1/stats" \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ --data-urlencode "scope=all" \
+ --data-urlencode "group_by=time,utm_campaign" \
+ --data-urlencode 'filters={"browser":["Chrome"],"device":["mobile"],"country":["United States","Germany"]}'
+```
+
+```python Python
+import requests
+
+response = requests.get(
+ "https://spoo.me/api/v1/stats",
+ params={
+ "scope": "all",
+ "group_by": "time,utm_campaign",
+ "browser": "Chrome",
+ "device": "mobile",
+ "country": "United States,Germany",
+ },
+ headers={"Authorization": "Bearer YOUR_API_KEY"},
+)
+print(response.json()["metrics"]["clicks_by_utm_campaign"])
+```
+
+
+
+
+ Filter values are case-sensitive. `chrome` will not match `Chrome`. The `short_code` filter is ignored on `scope=anon` queries, where the target link is already fixed by the `short_code` parameter.
+
+
+## How device is determined
+
+There is no reliable "device type" field in a user agent, so spoo.me derives one:
+
+- iOS resolves to `mobile`, or `tablet` for iPads.
+- Android resolves to `mobile` when the UA carries the `Mobile` token, `tablet` otherwise.
+- Desktop OS families (Windows, macOS, Linux, Chrome OS) resolve to `desktop`.
+- Anything unclassifiable lands in `unknown`.
+
+These signals survive Chrome's user agent reduction. One known limit: iPad Safari has sent a Mac user agent since iPadOS 13, so those visits count as `desktop`. This is indistinguishable server-side and matches how GA4, Adobe, and Matomo count it.
+
+## Sentinel values
+
+Some dimension values stand in for "no data" and are filterable like any real value:
+
+| Dimension | Sentinel | Meaning |
+| --- | --- | --- |
+| `referrer` | `Direct` | The visitor arrived with no referrer header |
+| `device` | `unknown` | The user agent could not be classified. Also covers clicks recorded before device tracking existed |
+| `utm_source`, `utm_medium`, `utm_campaign` | `(none)` | The click carried no such tag |
+
+Filtering by a sentinel matches both the stored sentinel value and older clicks where the field is absent, so `device=unknown` and `utm_source=(none)` return complete results.
+
+## UTM tags: recorded, not forwarded
+
+When someone clicks `spoo.me/acme?utm_source=newsletter&utm_medium=email`, the UTM parameters are recorded in your analytics and the visitor is redirected to the link's stored destination. **The UTM parameters are not appended to the destination URL.** If your destination needs its own campaign tags, put them in the long URL when you create the link.
+
+UTM dimensions are also **never shown on public stats pages**. Campaign tagging is your marketing data; only you (and your API credentials) can query it. See [Public stats](/analytics/public-stats).
+
+## Date ranges and timezones
+
+- `start_date` and `end_date` accept ISO 8601 strings (`2026-01-01T00:00:00Z`) or Unix timestamps in seconds.
+- Omit both and you get the last 7 days. Omit one and the other anchors a 7-day window (or runs to now).
+- A single query can span at most **90 days**. Longer ranges return a 400; page through them in chunks.
+- `timezone` accepts any IANA name (`America/New_York`) and controls how time buckets are cut and labeled. Defaults to UTC.
diff --git a/analytics/exports.mdx b/analytics/exports.mdx
new file mode 100644
index 0000000..57f19d0
--- /dev/null
+++ b/analytics/exports.mdx
@@ -0,0 +1,86 @@
+---
+title: "Exporting Analytics"
+description: "Download click data as JSON, CSV, XLSX, or XML"
+icon: "file-down"
+---
+
+`GET /api/v1/export` returns your analytics as a downloadable file. It accepts the same parameters as `GET /api/v1/stats` (scope, date range, `group_by`, filters, timezone) plus a required `format`, so anything you can query you can export.
+
+## Formats
+
+| `format` | You get |
+| --- | --- |
+| `json` | A single JSON file (`spoo-me-export.json`) with the full stats response |
+| `xml` | A single XML file with the same data |
+| `xlsx` | An Excel workbook: a **Summary** sheet plus one sheet per metric-dimension pair |
+| `csv` | A **ZIP archive** (`spoo-me-export-csv.zip`) containing `summary.csv` plus one CSV file per metric-dimension pair |
+
+
+ `format=csv` returns a ZIP, not a bare CSV. CSV has no concept of multiple tables, so each grouped breakdown ships as its own file inside the archive.
+
+
+## What the files contain
+
+The summary (sheet or `summary.csv`) carries the totals:
+
+| Field | Meaning |
+| --- | --- |
+| `total_clicks` | All clicks in the range |
+| `unique_clicks` | Clicks deduplicated by visitor IP |
+| `first_click` | Timestamp of the first click in range |
+| `last_click` | Timestamp of the most recent click in range |
+| `avg_redirection_time` | Average redirect latency in ms; empty when no timings exist |
+
+Each dimension file or sheet is named after its metric key, e.g. `clicks_by_time` or `unique_clicks_by_country`, with one row per dimension value: the value itself, the metric count, and its percentage share. Which files you get follows your `group_by`; the default is grouping by `time` with both metrics.
+
+## Examples
+
+Export one link's last 30 days as an Excel workbook:
+
+
+
+```bash curl
+curl -G "https://spoo.me/api/v1/export" \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ --data-urlencode "scope=all" \
+ --data-urlencode "short_code=acme" \
+ --data-urlencode "format=xlsx" \
+ --data-urlencode "start_date=2026-06-24T00:00:00Z" \
+ --data-urlencode "group_by=time,country,device" \
+ -o acme-stats.xlsx
+```
+
+```python Python
+import requests
+
+response = requests.get(
+ "https://spoo.me/api/v1/export",
+ params={
+ "scope": "all",
+ "short_code": "acme",
+ "format": "xlsx",
+ "start_date": "2026-06-24T00:00:00Z",
+ "group_by": "time,country,device",
+ },
+ headers={"Authorization": "Bearer YOUR_API_KEY"},
+)
+with open("acme-stats.xlsx", "wb") as f:
+ f.write(response.content)
+```
+
+
+
+The response is a binary download with a `Content-Disposition` header carrying the filename.
+
+Exports work anonymously too, for any link whose stats are public: use `scope=anon` with a `short_code` and skip the auth header.
+
+## Limits
+
+Export generation is resource-intensive, so it has tighter limits than the rest of the API:
+
+| Caller | Limit |
+| --- | --- |
+| Authenticated | 30/min, 1,000/day |
+| Anonymous | 10/min, 200/day |
+
+Date ranges are capped at 90 days per export, same as stats queries. For longer histories, export in chunks.
diff --git a/analytics/link-previews.mdx b/analytics/link-previews.mdx
new file mode 100644
index 0000000..1c17e49
--- /dev/null
+++ b/analytics/link-previews.mdx
@@ -0,0 +1,76 @@
+---
+title: "Link Previews"
+description: "Inspect where a short link leads before following it"
+icon: "eye"
+---
+
+Append `+` to any short link to see where it goes without visiting it:
+
+```
+spoo.me/{code}+
+```
+
+For `spoo.me/acme`, the preview lives at `spoo.me/acme+`. It works for every link on the default domain and requires no account.
+
+
+
+
+
+
+## Why it exists
+
+Short links hide their destination by design, and that opacity is exactly what scammers exploit. The preview page restores trust before the click: anyone handed a spoo.me link can check what is behind it first. If you are ever unsure about a short link, add the `+` before you open it, and if what you find looks malicious, [report it](/trust/reporting-links).
+
+## What the page shows
+
+| Fact | Notes |
+| --- | --- |
+| Alias and short URL | The link being previewed |
+| Status | `active`, `inactive`, `expired`, or `blocked` |
+| Created date | When the link was made |
+| Password protection | Whether a password gates the redirect |
+| Destination | The full destination URL, split into domain and path, with an HTTPS indicator |
+| Geo destinations | For geo-targeted links, every country rule and its destination |
+
+Geo-targeted links list **every** country rule, nothing summarized. A link cannot show one destination to the preview and send certain countries somewhere undisclosed.
+
+Everyone sees the same preview. There is no owner view, and owner-configured social media tags never appear here; the page reflects only where the redirect actually goes.
+
+## When the destination is withheld
+
+The preview never reveals a destination the redirect itself would refuse to serve. The destination (and any geo destinations) appear only while the link is **active and not password-protected**:
+
+| Link state | Preview shows |
+| --- | --- |
+| Active | Full destination |
+| Password-protected | Status and facts, destination withheld |
+| Inactive or expired | Status stated, destination withheld |
+| Blocked | `blocked` status, destination withheld |
+| Nonexistent code | 404 |
+
+Expired, deactivated, and blocked links still answer with their status, so a dead link is distinguishable from one that never existed.
+
+## API
+
+The page is backed by `GET /api/v1/public/preview/{code}`:
+
+
+
+```bash curl
+curl "https://spoo.me/api/v1/public/preview/acme"
+```
+
+```python Python
+import requests
+
+response = requests.get("https://spoo.me/api/v1/public/preview/acme")
+preview = response.json()
+if preview["destination"]:
+ print(preview["destination"]["url"])
+else:
+ print("destination withheld, status:", preview["status"])
+```
+
+
+
+No authentication. Rate limit: 30/min, 2,000/day.
diff --git a/analytics/link-stats.mdx b/analytics/link-stats.mdx
new file mode 100644
index 0000000..7928838
--- /dev/null
+++ b/analytics/link-stats.mdx
@@ -0,0 +1,80 @@
+---
+title: "Link Stats"
+description: "The per-link page: full analytics plus every setting, editable after creation"
+icon: "activity"
+---
+
+Every link has its own page at `/dashboard/links/{domain}/{alias}`: full analytics for that one link, and an editor for every setting the composer offers, including targeting and metadata. Open it from the Links page via **Full page** in a row's actions menu, or search the alias in the command palette (`⌘K`).
+
+
+
+
+
+
+The header carries the short URL with a copy button, the status chip, the destination, and the same actions menu as the list row.
+
+## Analytics
+
+Four tiles summarize the link:
+
+| Tile | What it measures |
+|---|---|
+| Total clicks | Every recorded click |
+| Unique visitors | Clicks deduplicated by visitor |
+| Unique rate | Share of clicks that were first-time visitors |
+| Avg redirect | Mean redirect latency in milliseconds |
+
+Below them, **Clicks over time** plots the click history with a total, unique, or both toggle and 24h, 7d, 30d, and 90d ranges. **Referrers** and **Countries** panels rank where the traffic came from, with favicons and flags. The full dimension set, including browser, OS, device, and UTM tags, is documented in [Dimensions](/analytics/dimensions) and queryable through the [stats API](/analytics/overview#link-level-vs-account-level).
+
+## Settings
+
+Everything set at creation is editable here, in place. Change a field and **Save changes**.
+
+
+
+
+
+
+| Setting | Notes |
+|---|---|
+| Destination | Point the short link somewhere new; existing clicks and stats stay attached |
+| Short link | Domain dropdown plus alias field. Moving domains or renaming works, but the old address stops resolving |
+| Password | Add, change, or remove. See [Password Protection](/links/password-protection) |
+| Expires / Max clicks | Set, extend, or clear either budget. See [Link Expiration](/links/link-expiration) |
+| Block bots | Keep crawlers and preview bots out of the redirect |
+| Private stats | Turn the [public stats page](/analytics/public-stats) off or on |
+
+
+ Changing the alias breaks the old address immediately; anything already sharing the old short link will 404. The editor warns you before you save.
+
+
+### Targeting
+
+The targeting section edits the link's [geo rules](/links/geo-targeting) after creation: pick a country from the searchable list of 250, give it a destination, and add or remove rules freely. Visitors from unlisted countries get the default destination.
+
+
+
+
+
+
+### Metadata
+
+The metadata section edits the link's [custom social preview](/links/custom-link-previews): title, description, an image by URL or upload, and a theme color (hex input plus swatches) that tints the embed accent on Discord. The live preview renders the card as X, WhatsApp, Discord, Slack, or LinkedIn would show it, and **Reset to destination** drops the overrides so previews come from the destination page again.
+
+
+
+
+
+
+## Link stats or the Analytics board?
+
+Both views are backed by the same data. Pick by the question you are asking:
+
+| You want to | Use |
+|---|---|
+| Inspect one link's performance and edit it in the same breath | This page |
+| Compare links, compose custom charts, cross-filter by dimension | The [Analytics board](/analytics/custom-dashboards) |
+| Watch one link alongside account-wide widgets | [Pin the link](/links/managing-links#pin-a-links-chart-to-analytics) to the board |
+| Share a link's numbers with someone who has no account | Its [public stats page](/analytics/public-stats) |
+
+The rule of thumb: this page answers "how is this link doing, and should I change it," while the board answers "what is happening across my traffic."
diff --git a/analytics/overview.mdx b/analytics/overview.mdx
new file mode 100644
index 0000000..ee9d29b
--- /dev/null
+++ b/analytics/overview.mdx
@@ -0,0 +1,91 @@
+---
+title: "Analytics Overview"
+description: "What spoo.me tracks on every click and where to see it"
+icon: "chart-line"
+---
+
+Every short link on spoo.me comes with click analytics. No setup, no tracking snippet: the redirect itself is the measurement point, so data starts flowing from the first click.
+
+## What gets tracked per click
+
+Each click on a short link records:
+
+| Field | What it captures |
+| --- | --- |
+| Time | When the click happened, bucketed by hour, day, week, or month depending on the range you query |
+| Browser | Browser family, e.g. Chrome, Firefox, Safari |
+| Operating system | OS family, e.g. Windows, macOS, Android, iOS |
+| Device | `mobile`, `tablet`, or `desktop`, derived from the user agent |
+| Country and city | Geolocated from the visitor's IP address |
+| Referrer | The domain the visitor came from, e.g. `google.com`. Only the domain is stored, never the full referring URL |
+| UTM tags | `utm_source`, `utm_medium`, and `utm_campaign` query parameters present on the short link when it was clicked |
+| Redirect time | How long the redirect took, in milliseconds |
+
+Two metrics are computed over this data: **clicks** (every recorded click) and **unique clicks** (deduplicated by visitor IP). When a link has at least one click, the API also derives a unique click rate, repeat click rate, and average clicks per visitor.
+
+
+ `avg_redirection_time` is `null` when no redirect timings exist in the queried range. It is never reported as `0`, since a zero would read as an instant redirect rather than an absence of data.
+
+
+Bot traffic is detected on every click. If a link has bot blocking enabled, bot clicks are kept out of its analytics entirely. See [Link safety](/trust/link-safety) for details.
+
+## The analytics dashboard
+
+The dashboard has two boards: **Overview**, a daily briefing across your account, and **Analytics**, a widget grid you compose yourself. Each widget on the Analytics board shows one slice of your data, such as clicks over time, top countries, or top referrers. Add, remove, resize, and rearrange widgets; the layout is saved to your account and follows you across devices. You can reset either board to its default layout at any time.
+
+
+
+
+
+
+## Link-level vs account-level
+
+Analytics are available at two levels, and both are backed by the same API:
+
+| View | Where | What it covers |
+| --- | --- | --- |
+| Account-level | Dashboard, or `GET /api/v1/stats` with `scope=all` | Aggregated clicks across every link you own, filterable down to specific links, countries, devices, and more |
+| Link-level | A link's stats view, or `GET /api/v1/stats` with `scope=anon&short_code=acme` | One link's clicks. Works without authentication as long as the link's stats are not private |
+
+Account-level queries require authentication. Link-level queries are public by default; the link owner can make them private, in which case only the owner sees them. See [Public stats](/analytics/public-stats).
+
+A quick account-level query:
+
+
+
+```bash curl
+curl "https://spoo.me/api/v1/stats?scope=all&group_by=time,country" \
+ -H "Authorization: Bearer YOUR_API_KEY"
+```
+
+```python Python
+import requests
+
+response = requests.get(
+ "https://spoo.me/api/v1/stats",
+ params={"scope": "all", "group_by": "time,country"},
+ headers={"Authorization": "Bearer YOUR_API_KEY"},
+)
+print(response.json()["summary"])
+```
+
+
+
+The response contains a `summary` block (totals, first and last click, average redirect time) and a `metrics` map keyed `{metric}_by_{dimension}`, e.g. `clicks_by_time` and `unique_clicks_by_country`.
+
+## Where to go next
+
+
+
+ Every dimension, grouping and filtering, and the UTM rules
+
+
+ Share a link's analytics with anyone, or keep them private
+
+
+ Let visitors inspect a destination before they click
+
+
+ Download your data as JSON, CSV, XLSX, or XML
+
+
diff --git a/analytics/public-stats.mdx b/analytics/public-stats.mdx
new file mode 100644
index 0000000..08fe3ac
--- /dev/null
+++ b/analytics/public-stats.mdx
@@ -0,0 +1,117 @@
+---
+title: "Public Stats Pages"
+description: "Every link has a shareable stats page, and you control who sees it"
+icon: "share-2"
+---
+
+Every short link has a public stats page at:
+
+```
+spoo.me/stats/{code}
+```
+
+For `spoo.me/acme`, that is `spoo.me/stats/acme`. Share it with a client, drop it in a report, or link it from a campaign recap. No account is needed to view it.
+
+
+
+
+
+
+## What visitors see
+
+The page shows the link's public facts followed by its click analytics:
+
+| Section | Contents |
+| --- | --- |
+| Link facts | Alias, short URL, destination, creation date, status, click limit, and whether bot blocking or password protection is on |
+| Charts | Clicks and unique clicks over time, plus breakdowns by browser, OS, device, country, city, and referrer |
+
+The default view covers the last 7 days; visitors can change the range up to 90 days per query. Two things are always withheld from the public view:
+
+- **The destination URL is shown only while the link is active.** Expired, deactivated, or blocked links keep their destination hidden.
+- **UTM campaign dimensions never appear.** Campaign tagging is the owner's marketing data.
+
+The API behind the page is `GET /api/v1/public/stats/{code}`:
+
+
+
+```bash curl
+curl "https://spoo.me/api/v1/public/stats/acme?timezone=America/New_York"
+```
+
+```python Python
+import requests
+
+response = requests.get(
+ "https://spoo.me/api/v1/public/stats/acme",
+ params={"timezone": "America/New_York"},
+)
+data = response.json()
+print(data["link"]["status"], data["stats"]["summary"])
+```
+
+
+
+## Password-protected links
+
+If the link itself is password-protected, its stats page is locked behind the same password. The unlock is a `POST` to the same endpoint with the password in the JSON body:
+
+
+
+```bash curl
+curl -X POST "https://spoo.me/api/v1/public/stats/acme" \
+ -H "Content-Type: application/json" \
+ -d '{"password": "secure@123"}'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/public/stats/acme",
+ json={"password": "secure@123"},
+)
+```
+
+
+
+A locked link answers `401` with code `password_required`; a wrong password answers `401` with code `invalid_password` and can be retried.
+
+
+ `GET` never accepts a password. A `?password=` query parameter is ignored, by design: passwords in URLs end up in logs, browser history, and referrer headers. Send the password in a `POST` body only.
+
+
+## Private stats
+
+You can take a link's stats page out of public view entirely. A link with private stats responds to everyone except you with a **404 that is byte-identical to the response for a code that does not exist**. Outsiders cannot tell "this link has private stats" apart from "this link was never created", so making stats private does not leak that the link exists.
+
+When you are signed in as the link's owner, none of the gates apply: you see private stats and skip the password prompt.
+
+## Toggling privacy
+
+
+
+ Open the link's settings and switch **Private stats** on or off.
+
+
+ Send `private_stats` in a link update:
+
+ ```bash
+ curl -X PATCH "https://spoo.me/api/v1/urls/URL_ID" \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"private_stats": true}'
+ ```
+
+ You can also set `private_stats` at creation time on `POST /api/v1/shorten`.
+
+
+
+## Rate limits
+
+| Caller | Limit |
+| --- | --- |
+| Anonymous | 20/min, 500/day |
+| Authenticated | 60/min, 2,000/day |
+
+The anonymous bucket also serves as the password-guess budget for locked pages.
diff --git a/api-keys.mdx b/api-keys.mdx
index 2dcc41e..a194d2a 100644
--- a/api-keys.mdx
+++ b/api-keys.mdx
@@ -12,30 +12,7 @@ API keys provide programmatic access to the Spoo.me API without requiring intera
API keys are only available in **API v1**. The legacy v0 API does not support API key authentication.
-## Authentication Methods
-
-Spoo.me v1 API supports three authentication methods:
-
-
-
- No authentication required
- - Lower rate limits
- - Cannot manage URLs later
- - Limited features
-
-
- Interactive user sessions
- - Full dashboard access
- - Manage URLs via web UI
- - Highest privileges
-
-
- Programmatic access
- - Scoped permissions
- - Long-lived tokens
- - Perfect for automation
-
-
+API keys are one of three ways to call the API: anonymous requests work for basic shortening, JWT sessions power the dashboard, and API keys are the right tool for scripts, servers, and CI. See [Authentication](/api-reference/authentication) for how the three methods compare and when to use each.
## API Key Scopes
@@ -43,11 +20,17 @@ API keys can be granted specific permissions (scopes) to limit their access:
| Scope | Description | Endpoints |
|-------|-------------|-----------|
-| `shorten:create` | Create new shortened URLs | `POST /api/v1/shorten` |
-| `urls:read` | View URL details and list | `GET /api/v1/urls` |
-| `urls:manage` | Update and delete URLs | `PATCH/DELETE /api/v1/urls/*` |
-| `stats:read` | Access analytics data | `GET /api/v1/stats` |
-| `admin:all` | Full administrative access | All endpoints |
+| `shorten:create` | Create new shortened URLs | `POST /api/v1/shorten`, `GET /api/v1/shorten/check-alias` |
+| `urls:read` | View URLs and their analytics | `GET /api/v1/urls`, `GET /api/v1/stats`, `GET /api/v1/export` |
+| `urls:manage` | Update and delete URLs (includes read access) | `PATCH/DELETE /api/v1/urls/*` plus everything `urls:read` grants |
+| `stats:read` | Access analytics data only | `GET /api/v1/stats`, `GET /api/v1/export` |
+| `domains:read` | View custom domains | `GET /api/v1/custom-domains*` |
+| `domains:manage` | Register, verify, configure, and remove custom domains (includes read access) | All `/api/v1/custom-domains*` endpoints |
+| `admin:all` | Everything above | All endpoints except API key management |
+
+
+ API keys can never manage other API keys. The `/api/v1/keys` endpoints require an interactive JWT session, and `admin:all` does not include them. This is deliberate: a leaked key cannot be used to mint new keys.
+
**Principle of Least Privilege**: Only grant the minimum scopes needed for your use case. For example, if you only need to shorten URLs, use `shorten:create` instead of `admin:all`.
@@ -58,7 +41,13 @@ API keys can be granted specific permissions (scopes) to limit their access:
### Via Web Dashboard
1. Log in to your [Spoo.me Dashboard](https://spoo.me/dashboard)
-2. Navigate to **Settings** → **API Keys**
+2. Open the [API Keys page](https://spoo.me/dashboard/keys)
+
+
+
+
+
+
3. Click **Create New API Key**
4. Configure your key:
- **Name**: Human-readable identifier (e.g., "Production Server")
@@ -66,12 +55,22 @@ API keys can be granted specific permissions (scopes) to limit their access:
- **Scopes**: Select required permissions
- **Expiration**: Optional expiration date
5. Click **Create**
+
+
+
+
+
+
6. **Copy the key immediately** - it won't be shown again!
**One-Time Display**: The full API key is shown **only once** at creation. Store it securely immediately. If you lose it, you'll need to create a new key.
+
+ Creating API keys requires a **verified email address**. If your email is unverified, key creation is rejected until you complete verification from the dashboard.
+
+
## Using API Keys
API keys must be sent in the `Authorization` header with the `Bearer` scheme:
@@ -106,24 +105,9 @@ curl -X GET "https://spoo.me/api/v1/urls?page=1&pageSize=20" \
-H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"
```
-## Rate Limits with API Keys
+## Rate Limits
-API keys grant **authenticated rate limits**, which are significantly higher than anonymous limits:
-
-
-
- - **60 requests/minute**
- - **5000 requests/day**
- - Access to management endpoints
- - Private stats support
-
-
- - **20 requests/minute**
- - **1000 requests/day**
- - Cannot manage URLs
- - Public URLs only
-
-
+API keys get the authenticated tiers, which are substantially higher than anonymous limits and unlock endpoints anonymous callers cannot use at all. Every endpoint's budget is listed on the [rate limits page](/rate-limits).
## Security Best Practices
diff --git a/api-reference/authentication.mdx b/api-reference/authentication.mdx
new file mode 100644
index 0000000..f72932a
--- /dev/null
+++ b/api-reference/authentication.mdx
@@ -0,0 +1,162 @@
+---
+title: "Authentication"
+description: "How to authenticate with the Spoo.me API: API keys, JWT sessions, and anonymous access"
+icon: "key-round"
+---
+
+The Spoo.me API accepts three kinds of callers. Pick the one that matches how your code runs:
+
+| Method | Credential | Best for |
+|--------|-----------|----------|
+| **API key** | `Authorization: Bearer spoo_...` | Scripts, servers, CI, integrations |
+| **JWT session** | `Authorization: Bearer ` or `access_token` cookie | The web dashboard and connected apps |
+| **Anonymous** | None | One-off link creation, public reads |
+
+Authentication is resolved in a fixed order: a bearer token starting with `spoo_` is treated as an API key, any other bearer token is treated as a JWT, then the `access_token` cookie is checked, and finally the request falls through to anonymous.
+
+## API keys
+
+API keys are long-lived tokens for programmatic access. Every key starts with the `spoo_` prefix and is sent in the `Authorization` header with the `Bearer` scheme:
+
+```text
+Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz
+```
+
+
+
+```bash curl
+curl -X POST "https://spoo.me/api/v1/shorten" \
+ -H "Authorization: Bearer spoo_YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"long_url": "https://example.com/very/long/url"}'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/shorten",
+ headers={"Authorization": "Bearer spoo_YOUR_API_KEY"},
+ json={"long_url": "https://example.com/very/long/url"},
+)
+print(response.json())
+```
+
+
+
+Keys are created from the dashboard or via `POST /api/v1/keys`, carry an explicit scope list, and can be given an expiration date. The full token is shown exactly once at creation. See the [API keys guide](/api-keys) for creation, rotation, and storage practices.
+
+
+ A revoked, expired, or mistyped API key does not always produce an error. Protected endpoints return `401`, but on optional-auth endpoints the request silently proceeds as anonymous, with anonymous rate limits and no ownership. If links you create stop appearing in your account, check the key first.
+
+
+## JWT sessions
+
+Logging in on spoo.me issues a short-lived JWT, delivered as the `access_token` cookie for browser sessions. The same token is accepted in the `Authorization` header:
+
+```text
+Authorization: Bearer eyJhbGciOi...
+```
+
+Interactive sessions are unrestricted: they are not limited by scopes and can reach every endpoint, including API key management. Connected apps (CLI, Raycast, browser extensions) go through the device authorization flow and receive JWTs restricted to the scopes you approved at sign-in.
+
+## Anonymous access
+
+Public endpoints work without any credentials: shortening, alias checks, public link stats, link previews, exports, and abuse reports. Anonymous callers get lower [rate limits](/rate-limits), and links created anonymously have no owner, so they can never be edited or deleted later.
+
+## Scopes
+
+Scopes restrict what a credential may do. They apply to API keys and to connected-app tokens. Interactive dashboard sessions are never scope-restricted.
+
+| Scope | Grants |
+|-------|--------|
+| `shorten:create` | Create short links, check alias availability |
+| `urls:read` | List and read your links, plus everything `stats:read` grants, plus destination metadata fetch |
+| `urls:manage` | Everything `urls:read` grants, plus update, delete, and bulk operations |
+| `stats:read` | Link statistics and exports, including owner access on public stats |
+| `domains:read` | List and read your custom domains |
+| `domains:manage` | Everything `domains:read` grants, plus register, verify, configure, and remove domains |
+| `reports:create` | Submit abuse reports |
+| `webhooks:read` | List webhook endpoints and their deliveries |
+| `webhooks:manage` | Everything `webhooks:read` grants, plus create, update, delete, test, and retry |
+| `keys:manage` | List and delete API keys. Connected-app tokens only, see below |
+| `admin:all` | Everything above except API key management |
+
+Two scopes have deliberate carve-outs:
+
+- **`admin:all` never covers `/api/v1/keys`.** Key management requires an interactive session or the dedicated `keys:manage` scope.
+- **`keys:manage` cannot be granted to an API key.** Only a connected app can hold it, and even then it covers listing and deleting keys only. Creating a key always requires an interactive session.
+
+
+ Reading a webhook signing secret (`GET /api/v1/webhooks/{id}/secret`) is interactive-session only. Even `webhooks:manage` does not unlock it.
+
+
+## Optional-auth endpoints
+
+Several endpoints accept authentication without requiring it. Anonymous requests work, but sending credentials changes the behavior:
+
+- **Higher rate limits.** Authenticated callers get larger budgets on shorten, stats, exports, and reports. See [rate limits](/rate-limits).
+- **Ownership.** A link shortened with credentials belongs to your account and can be managed later. Anonymous links cannot.
+- **Private data.** On `GET /api/v1/public/stats/{short_code}`, the owner's session unlocks stats for links marked private and skips the password gate. Everyone else sees a private link answered exactly like a nonexistent one.
+- **Trust.** Authenticated abuse reports skip the captcha and may batch up to 100 items per request.
+
+## Verified email requirements
+
+Endpoints that create durable resources reject authenticated accounts whose email is not verified. Anonymous requests are unaffected where anonymous access exists.
+
+| Endpoint | Requirement |
+|----------|-------------|
+| `POST /api/v1/shorten` | Verified email when authenticated |
+| `GET /api/v1/shorten/check-alias` | Verified email when authenticated |
+| `POST /api/v1/keys` | Verified email, interactive session |
+| `POST /api/v1/custom-domains` | Verified email |
+| `/api/v1/webhooks/*` (all endpoints except the public event catalog) | Verified email |
+
+An unverified account gets a `403` with code `EMAIL_NOT_VERIFIED`. Verify from the email sent at signup, or request a new verification email from the dashboard.
+
+## Why key management is session-only
+
+Creating an API key requires a logged-in interactive session. API keys and connected-app tokens cannot call `POST /api/v1/keys`, and `admin:all` does not include it. This is a containment guarantee: if a key leaks, the attacker cannot use it to mint fresh keys that survive revocation. Revoke the leaked key and the damage stops there.
+
+## Error responses
+
+All errors use the same JSON envelope: `{"error": "", "code": ""}`. Validation errors may add `field` or `details`.
+
+**401 Unauthorized**: the request needs credentials and has none, or the token failed verification.
+
+```json
+{
+ "error": "Authentication required",
+ "code": "authentication_error"
+}
+```
+
+**403 Forbidden**: the credentials are valid but not sufficient. The `code` tells you which gate fired.
+
+```json
+{
+ "error": "Insufficient scope for this operation",
+ "code": "forbidden"
+}
+```
+
+```json
+{
+ "error": "Email verification required",
+ "code": "EMAIL_NOT_VERIFIED",
+ "message": "You must verify your email address before creating resources. Check your inbox for the verification code."
+}
+```
+
+Treat `401` as "get new credentials" and `403` as "these credentials will never work here": retrying a `403` without changing the key's scopes or verifying your email will keep failing.
+
+## Next steps
+
+
+
+ Create, scope, and rotate API keys
+
+
+ Per-endpoint budgets for authenticated and anonymous callers
+
+
diff --git a/custom-domains.mdx b/custom-domains.mdx
new file mode 100644
index 0000000..072f686
--- /dev/null
+++ b/custom-domains.mdx
@@ -0,0 +1,58 @@
+---
+title: "Custom Domains"
+description: "Serve short links from your own domain"
+icon: "globe"
+---
+
+Put your brand on every link you share. With a custom domain, your short links live on a domain you own: instead of `spoo.me/xyz` you share `ac.me/launch`. Same analytics, same management, your name on the door.
+
+Links on your domain behave exactly like links on spoo.me: full analytics, password protection, expiration, max clicks, everything. HTTPS is handled for you, with no certificates to manage.
+
+
+ Custom domain registration is being rolled out gradually, so it may not be available on your account yet. Managing already-connected domains is never restricted.
+
+
+## How it works
+
+
+
+ Add the domain in the dashboard. It starts in **Pending** status and you get the exact DNS records to publish.
+
+
+ Add a CNAME that routes traffic and a TXT record that proves ownership at your DNS provider.
+
+
+ Verification runs automatically once the records propagate. The domain flips to **Active** and TLS is issued.
+
+
+ Pick your domain in the link composer and `ac.me/launch` is live.
+
+
+
+
+
+
+
+
+## Guides
+
+
+
+ Register a domain from the dashboard and get your DNS records
+
+
+ Publish the records, understand the status lifecycle, fix stuck verifications
+
+
+ The domain picker, alias availability, and moving links between domains
+
+
+ Root redirect, not-found redirect, and custom robots.txt
+
+
+ Revoke a domain, cascade-delete its links, free the account slot
+
+
+ Manage domains programmatically, from registration to routing
+
+
diff --git a/custom-domains/add-a-domain.mdx b/custom-domains/add-a-domain.mdx
new file mode 100644
index 0000000..0a5e0b2
--- /dev/null
+++ b/custom-domains/add-a-domain.mdx
@@ -0,0 +1,59 @@
+---
+title: "Add a Domain"
+description: "Register a custom domain from the dashboard"
+icon: "plus"
+---
+
+Registering a domain takes one dialog. You give spoo.me the domain name, spoo.me gives you the DNS records to publish. This page covers the registration step; [DNS and verification](/custom-domains/dns-and-verification) covers everything after.
+
+## Before you start
+
+- **Verify your account email.** Domain registration requires a verified email. If yours is not verified yet, the dashboard prompts you before you can register.
+- **Own the domain.** You need access to the domain's DNS settings at your registrar or DNS provider.
+
+You can use an apex domain (`ac.me`) or a subdomain (`go.ac.me`). Subdomains are a good choice when the apex already serves your website.
+
+
+ The number of domains you can connect is shown on the Domains page. Registered domains count against the limit regardless of status, including revoked ones you have not yet [permanently deleted](/custom-domains/removing-a-domain#free-the-slot-permanent-delete).
+
+
+## Register the domain
+
+
+
+ In the dashboard, go to **Domains**. This lists every domain on your account with its current status.
+
+
+
+
+
+
+
+ Enter the domain exactly as you want it on your links, for example `ac.me`. Case does not matter; the domain is stored lowercased.
+
+
+
+
+
+
+
+ Confirm to register. The domain is created in **Pending** status and its detail page opens with the DNS records you need to publish.
+
+
+
+## What registration does
+
+Registering does two things:
+
+1. **Reserves the domain on your account.** It appears on the Domains page in **Pending** status. Links cannot resolve on it yet.
+2. **Issues your DNS records.** The domain page shows exactly which records to add at your DNS provider, with copy buttons next to each value: a CNAME that routes traffic to spoo.me and a TXT record that proves you own the domain.
+
+Nothing on your domain changes until you publish those records, so registering is safe to do at any time, even for a domain that currently serves a website.
+
+## Next step
+
+Publish the records and get the domain verified.
+
+
+ Add the records at your DNS provider and watch the domain go Active
+
diff --git a/custom-domains/api.mdx b/custom-domains/api.mdx
new file mode 100644
index 0000000..05301cf
--- /dev/null
+++ b/custom-domains/api.mdx
@@ -0,0 +1,179 @@
+---
+title: "Custom Domains API"
+description: "Manage domains programmatically, from registration to routing"
+icon: "code"
+---
+
+Everything the dashboard does with domains is a thin layer over these endpoints. This page is the quick tour; full request and response schemas live in the [API reference](/api-reference).
+
+## Endpoints
+
+| Endpoint | What it does | Scope | Rate limit |
+| ----------------------------------------------- | ---------------------------------------------- | ---------------- | -------------------------------- |
+| `POST /api/v1/custom-domains` | Register a domain | `domains:manage` | 10/hour |
+| `GET /api/v1/custom-domains` | List your domains (paginated) | `domains:read` | 60/min |
+| `GET /api/v1/custom-domains/{id}` | Fetch one domain | `domains:read` | 60/min |
+| `POST /api/v1/custom-domains/{id}/verify` | Trigger a verification check | `domains:manage` | 10/min, 60/hour per domain |
+| `PATCH /api/v1/custom-domains/{id}` | Update routing settings | `domains:manage` | 30/min |
+| `DELETE /api/v1/custom-domains/{id}` | Revoke (add `?cascade=true` to delete links) | `domains:manage` | 10/min |
+| `DELETE /api/v1/custom-domains/{id}/permanent` | Remove a revoked domain from the account | `domains:manage` | 10/min |
+
+Notes on auth:
+
+- `domains:manage` includes everything `domains:read` covers. A JWT session works for any operation without scopes.
+- Registration additionally requires a verified account email, including for API key callers.
+
+## Full flow: register, verify, shorten
+
+The complete life of `ac.me`, from nothing to a live branded link.
+
+### 1. Register the domain
+
+
+
+```bash cURL
+curl -X POST https://spoo.me/api/v1/custom-domains \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"fqdn": "ac.me"}'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/custom-domains",
+ headers={"Authorization": "Bearer YOUR_API_KEY"},
+ json={"fqdn": "ac.me"},
+)
+domain = response.json()
+print(domain["id"], domain["status"]) # e.g. 665f0c2f9e7a4b1d2c3d4e5f pending
+```
+
+
+
+The response carries the DNS records to publish, plus any setup notes specific to your domain:
+
+```json
+{
+ "id": "665f0c2f9e7a4b1d2c3d4e5f",
+ "fqdn": "ac.me",
+ "status": "pending",
+ "dns_records": [
+ {
+ "type": "CNAME",
+ "name": "ac.me",
+ "value": "customers.spoo.me",
+ "purpose": "routes traffic to spoo.me"
+ },
+ {
+ "type": "TXT",
+ "name": "_cf-custom-hostname.ac.me",
+ "value": "3b1f9a52-...-verification-token",
+ "purpose": "proves domain ownership"
+ }
+ ],
+ "setup_notes": [],
+ "last_verification_error": null
+}
+```
+
+### 2. Publish DNS, then verify
+
+Add the records at your DNS provider (see [DNS and verification](/custom-domains/dns-and-verification)). Verification happens automatically once the CNAME propagates; call the verify endpoint to trigger a fresh check and read back the current state.
+
+
+
+```bash cURL
+curl -X POST https://spoo.me/api/v1/custom-domains/665f0c2f9e7a4b1d2c3d4e5f/verify \
+ -H "Authorization: Bearer YOUR_API_KEY"
+```
+
+```python Python
+import time
+import requests
+
+headers = {"Authorization": "Bearer YOUR_API_KEY"}
+domain_id = "665f0c2f9e7a4b1d2c3d4e5f"
+
+while True:
+ response = requests.post(
+ f"https://spoo.me/api/v1/custom-domains/{domain_id}/verify",
+ headers=headers,
+ )
+ domain = response.json()
+ if domain["status"] == "active":
+ break
+ print("not yet:", domain["last_verification_error"])
+ time.sleep(120) # DNS propagation takes minutes; poll gently
+```
+
+
+
+On failure, `last_verification_error` names the problem, for example a missing CNAME. Attempts are capped at 60 per domain per hour, so poll on the order of minutes, not seconds.
+
+### 3. Create a link on the domain
+
+Once the status is `active`, pass `domain` to the shorten endpoint:
+
+
+
+```bash cURL
+curl -X POST https://spoo.me/api/v1/shorten \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "url": "https://acme.com/product-launch",
+ "alias": "launch",
+ "domain": "ac.me"
+ }'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/shorten",
+ headers={"Authorization": "Bearer YOUR_API_KEY"},
+ json={
+ "url": "https://acme.com/product-launch",
+ "alias": "launch",
+ "domain": "ac.me",
+ },
+)
+print(response.json()["short_url"]) # https://ac.me/launch
+```
+
+
+
+`https://ac.me/launch` is live.
+
+## Related link endpoints
+
+Domains also appear as a parameter across the link APIs:
+
+| Endpoint | Domain usage |
+| ------------------------------------- | -------------------------------------------------------- |
+| `POST /api/v1/shorten` | `domain` in the body creates the link on that domain |
+| `GET /api/v1/shorten/check-alias` | `?domain=` scopes the availability check |
+| `GET /api/v1/urls` | `?domain=` filters the list to one domain |
+| `PATCH /api/v1/urls/{id}` | `domain` in the body moves the link (`null` = spoo.me) |
+| `POST /api/v1/urls/bulk/domain` | Moves up to 100 links to one target domain |
+| `DELETE /api/v1/urls?domain=` | Deletes all your links on a domain (5/min, 50/day) |
+
+## Reference
+
+
+
+ Full request and response schemas for every custom domain endpoint
+
+
+ Create a key with the domains:manage scope
+
+
+ How limits are counted across keys, sessions, and IPs
+
+
+ What the records mean and how to unstick a verification
+
+
diff --git a/custom-domains/create-links.mdx b/custom-domains/create-links.mdx
new file mode 100644
index 0000000..1ccc4ac
--- /dev/null
+++ b/custom-domains/create-links.mdx
@@ -0,0 +1,157 @@
+---
+title: "Create Links on Your Domain"
+description: "Shorten on your domain, check aliases, and move links between domains"
+icon: "link"
+---
+
+Once a domain is **Active**, it shows up everywhere links are created and managed: the composer, the links list, and the bulk actions. Links on your domain get the full feature set, including analytics, passwords, expiration, and max clicks.
+
+## Pick the domain in the composer
+
+The link composer has a domain picker next to the alias field. It defaults to `spoo.me`; select `ac.me` instead and the new link is created there.
+
+
+
+
+
+
+
+
+```bash cURL
+curl -X POST https://spoo.me/api/v1/shorten \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "url": "https://acme.com/product-launch",
+ "alias": "launch",
+ "domain": "ac.me"
+ }'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/shorten",
+ headers={"Authorization": "Bearer YOUR_API_KEY"},
+ json={
+ "url": "https://acme.com/product-launch",
+ "alias": "launch",
+ "domain": "ac.me",
+ },
+)
+print(response.json()["short_url"]) # https://ac.me/launch
+```
+
+
+
+## Aliases are per domain
+
+Each domain is its own namespace. `launch` being taken on `spoo.me` says nothing about `ac.me`: the alias check in the composer runs against the domain you picked, so switching domains re-checks availability live.
+
+Two upsides of your own namespace:
+
+- **Short, common aliases are usually free.** You are not competing with every spoo.me user for `launch` or `docs`.
+- **Reserved words do not apply.** Aliases that are reserved on `spoo.me` (they would collide with app routes) are available on your domain.
+
+Programmatically, pass `domain` to the availability check:
+
+
+
+```bash cURL
+curl "https://spoo.me/api/v1/shorten/check-alias?alias=launch&domain=ac.me" \
+ -H "Authorization: Bearer YOUR_API_KEY"
+```
+
+```python Python
+import requests
+
+response = requests.get(
+ "https://spoo.me/api/v1/shorten/check-alias",
+ headers={"Authorization": "Bearer YOUR_API_KEY"},
+ params={"alias": "launch", "domain": "ac.me"},
+)
+print(response.json()) # {"available": true, "reason": null}
+```
+
+
+
+## Move a link to another domain
+
+Any link you own can move between domains after creation: from `spoo.me` onto `ac.me`, between two custom domains, or back to `spoo.me`. Open the link's settings and change its domain.
+
+
+
+
+
+
+Rules that apply to a move:
+
+- The target must be a domain you own in **Active** status (or `spoo.me`).
+- The alias must be free on the target domain; availability is re-checked as part of the move. If it is taken, the move is rejected and nothing changes.
+- Moving back to `spoo.me` re-applies its reserved-alias rules, so a link whose alias is reserved there cannot move onto it.
+- The old address stops resolving immediately; the link keeps its stats, settings, and ID.
+
+
+
+```bash cURL
+curl -X PATCH https://spoo.me/api/v1/urls/URL_ID \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"domain": "ac.me"}'
+```
+
+```python Python
+import requests
+
+response = requests.patch(
+ "https://spoo.me/api/v1/urls/URL_ID",
+ headers={"Authorization": "Bearer YOUR_API_KEY"},
+ json={"domain": "ac.me"}, # or None to move back to spoo.me
+)
+```
+
+
+
+## Move links in bulk
+
+Migrating an existing collection? Select multiple links in the dashboard and move them to one target domain in a single action, or call the bulk endpoint with up to 100 link IDs. Each link gets its own verdict, so one taken alias does not fail the batch: that link reports a conflict and the rest move.
+
+
+
+```bash cURL
+curl -X POST https://spoo.me/api/v1/urls/bulk/domain \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "ids": ["URL_ID_1", "URL_ID_2", "URL_ID_3"],
+ "domain": "ac.me"
+ }'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/urls/bulk/domain",
+ headers={"Authorization": "Bearer YOUR_API_KEY"},
+ json={"ids": ["URL_ID_1", "URL_ID_2", "URL_ID_3"], "domain": "ac.me"},
+)
+for item in response.json()["results"]:
+ print(item["id"], item.get("error_code") or "moved")
+```
+
+
+
+
+ Retrying a bulk move is safe. Links that already sit on the target domain report success without changing anything, so you can re-send a failed batch as-is.
+
+
+## Filter your links by domain
+
+The links list has a domain filter, so you can work with one domain's links in isolation. The same filter exists on the API:
+
+```bash
+curl "https://spoo.me/api/v1/urls?domain=ac.me" \
+ -H "Authorization: Bearer YOUR_API_KEY"
+```
diff --git a/custom-domains/dns-and-verification.mdx b/custom-domains/dns-and-verification.mdx
new file mode 100644
index 0000000..318b1dc
--- /dev/null
+++ b/custom-domains/dns-and-verification.mdx
@@ -0,0 +1,94 @@
+---
+title: "DNS and Verification"
+description: "Publish your DNS records and get the domain to Active"
+icon: "shield-check"
+---
+
+After [registering a domain](/custom-domains/add-a-domain), you publish the DNS records shown on its domain page. Once they propagate, verification and TLS issuance complete automatically and the domain goes **Active**.
+
+## The records
+
+The domain page lists the exact records for your domain, with copy buttons next to each value. For `ac.me` on spoo.me they look like this:
+
+| Type | Name | Value | Purpose |
+| ----- | --------------------------- | -------------------- | -------------------------- |
+| CNAME | `ac.me` | `customers.spoo.me` | Routes traffic to spoo.me |
+| TXT | `_cf-custom-hostname.ac.me` | verification token | Proves you own the domain |
+
+- **The CNAME routes traffic.** Every request to `ac.me` lands on spoo.me's edge, which serves your short links.
+- **The TXT record proves ownership.** The token is unique to your registration, so nobody else can claim your domain.
+
+
+ Always publish exactly what your domain page shows. The records can differ by domain, and the page may include extra setup notes specific to yours. Apex domains need a DNS provider that supports CNAME at the root (often called CNAME flattening, ALIAS, or ANAME).
+
+
+## Add them at your DNS provider
+
+
+
+ Sign in to wherever your domain's DNS is managed (Cloudflare, Namecheap, Route 53, your registrar's panel).
+
+
+ Create a CNAME for the name shown on the domain page, pointing at `customers.spoo.me`. Some providers want the bare name (`@` for an apex, `go` for `go.ac.me`) instead of the full domain.
+
+ If your provider proxies records by default (Cloudflare's orange cloud), set this record to **DNS only**.
+
+
+ Create the TXT record with the name and token value exactly as shown. Do not add quotes or trailing spaces.
+
+
+
+
+
+
+
+ New records usually propagate in a few minutes, but it can take up to an hour, and in rare cases longer, depending on your provider and the previous record's TTL.
+
+
+
+## Verification
+
+Verification is automatic. Once the CNAME is live and propagated, spoo.me's edge confirms ownership over HTTP and the domain transitions to **Active**, no clicking required. The dashboard polls the status for you while you are on the domain page.
+
+Impatient? Click **Start verification** on the domain page to trigger a fresh check immediately. If a check fails, the page shows the exact reason (the `last_verification_error`), for example a missing CNAME or a token mismatch.
+
+TLS is part of the same flow: when verification succeeds, a certificate is issued for your domain automatically, and it renews itself. You never manage certificates.
+
+
+
+
+
+
+## Status lifecycle
+
+| Status | Meaning |
+| ------------- | --------------------------------------------------------------------------------------------- |
+| **Pending** | Registered, awaiting verification. Publish the DNS records to move forward. |
+| **Verifying** | A DNS check is in progress. |
+| **Active** | Verified and serving. TLS is issued and links on the domain resolve. |
+| **Suspended** | Re-verification kept failing, or the domain was suspended by an admin. Links stop resolving. |
+| **Revoked** | Removed by you or force-removed for abuse. Terminal: a revoked domain never serves again. |
+
+## Ongoing re-verification
+
+Active domains are re-checked in the background, by default every 7 days, to confirm the DNS records are still in place. A single failed check does nothing; after 3 consecutive failures the domain is **Suspended**: no new certificates are issued and links on it stop resolving.
+
+Suspension is recoverable. Restore the records shown on the domain page, then click **Start verification**. When the check passes, the domain returns to **Active** and links resolve again.
+
+
+ Keep the DNS records in place permanently. Removing them after activation takes the domain offline within days.
+
+
+## Troubleshooting
+
+### Stuck in Pending or Verifying
+
+- **Record typos.** Compare the published records character for character against the domain page. A common miss is the TXT record name: it must be `_cf-custom-hostname.ac.me`, not `ac.me`.
+- **Proxied CNAME.** If the CNAME sits behind another proxy (Cloudflare's orange cloud on your own zone), verification cannot see the real target. Switch the record to **DNS only**.
+- **CAA records.** If your domain has CAA records that restrict which certificate authorities may issue for it, they can block TLS issuance even when ownership verification succeeds. Remove them, or loosen them to permit issuance, then verify again.
+- **Propagation.** Check what the world sees with `dig ac.me CNAME +short`. If it does not return `customers.spoo.me` yet, wait and retry. Verification attempts are capped at 60 per domain per hour, so give propagation time between clicks.
+- **Read the error.** The domain page surfaces the exact failure reason from the last check. It usually names the missing or mismatched record.
+
+### Suspended
+
+The DNS records were removed or changed after activation. Restore the CNAME and TXT records exactly as shown on the domain page, wait for propagation, then click **Start verification**. The domain returns to Active once a check passes.
diff --git a/custom-domains/removing-a-domain.mdx b/custom-domains/removing-a-domain.mdx
new file mode 100644
index 0000000..8b45b77
--- /dev/null
+++ b/custom-domains/removing-a-domain.mdx
@@ -0,0 +1,84 @@
+---
+title: "Removing a Domain"
+description: "Revoke a domain, decide what happens to its links, free the slot"
+icon: "trash-2"
+---
+
+Removal is a two-stage process by design. **Revoking** takes the domain offline and is terminal for the domain itself. **Permanent delete** then removes the revoked record from your account and frees the slot. The split keeps an audit trail and gives you a moment between "stop serving" and "erase".
+
+## Revoke a domain
+
+Revoke from the danger zone at the bottom of the domain page.
+
+
+
+
+
+
+What revoking does:
+
+- **Links on the domain stop resolving immediately.** Anyone opening `ac.me/launch` gets nothing.
+- **The status becomes Revoked, which is terminal.** A revoked domain never serves again. To use the domain in the future you register it from scratch and go through DNS verification again.
+- **The slot stays occupied.** A revoked domain still counts against your domain limit until you permanently delete it.
+
+
+ There is no undo. Every link on the domain goes dark the moment you confirm, including any you have printed, embedded, or shipped in emails.
+
+
+## What happens to the links
+
+You choose at revoke time:
+
+| Option | Effect on the domain's links |
+| ------------------------- | ----------------------------------------------------------------------------------------- |
+| **Keep links** (default) | Links stay in your account with their stats, but are unreachable since the domain no longer serves. |
+| **Delete links** (cascade) | Every link you own on the domain is deleted along with its stats. |
+
+Kept links remain visible in your links list. If you want them live again, [move them to another domain](/custom-domains/create-links#move-links-in-bulk) before revoking; a move after the fact works too, the links just stay dark until you do it.
+
+Over the API, cascade is a query flag on the revoke call:
+
+
+
+```bash cURL
+# Revoke, keep the links
+curl -X DELETE https://spoo.me/api/v1/custom-domains/DOMAIN_ID \
+ -H "Authorization: Bearer YOUR_API_KEY"
+
+# Revoke and delete every link on the domain
+curl -X DELETE "https://spoo.me/api/v1/custom-domains/DOMAIN_ID?cascade=true" \
+ -H "Authorization: Bearer YOUR_API_KEY"
+```
+
+```python Python
+import requests
+
+# Revoke and delete every link on the domain
+response = requests.delete(
+ "https://spoo.me/api/v1/custom-domains/DOMAIN_ID",
+ headers={"Authorization": "Bearer YOUR_API_KEY"},
+ params={"cascade": "true"},
+)
+print(response.json()["urls_deleted"])
+```
+
+
+
+
+ Want to clear out a domain's links without touching the domain? Use the bulk delete on the links list, or `DELETE /api/v1/urls?domain=ac.me`, which deletes every link you own on that domain and leaves the domain active.
+
+
+## Free the slot: permanent delete
+
+A revoked domain lingers on your Domains page so the record of what happened is preserved. When you no longer need it, permanently delete it from the same danger zone. This removes the domain from your account and frees the slot toward your domain limit.
+
+Permanent delete only works on domains that are already revoked.
+
+```bash
+curl -X DELETE https://spoo.me/api/v1/custom-domains/DOMAIN_ID/permanent \
+ -H "Authorization: Bearer YOUR_API_KEY"
+```
+
+## After removal
+
+Your DNS records are yours to clean up: delete the CNAME and TXT records at your provider once the domain is revoked. Nothing breaks if you leave them, but stale records pointing at a service you no longer use are worth removing.
diff --git a/custom-domains/routing.mdx b/custom-domains/routing.mdx
new file mode 100644
index 0000000..58833d2
--- /dev/null
+++ b/custom-domains/routing.mdx
@@ -0,0 +1,99 @@
+---
+title: "Routing Settings"
+description: "Root redirect, not-found redirect, and custom robots.txt"
+icon: "signpost"
+---
+
+Short links only cover paths that match an alias. Routing settings decide everything else on your domain: the bare domain itself, paths that match nothing, and what crawlers are told. All three live on the domain page and take effect only while the domain is **Active**.
+
+
+
+
+
+
+## The three settings
+
+| Setting | Applies to | When blank |
+| ---------------------- | ----------------------------------------- | ------------------------------------------------- |
+| **Root redirect** | Visits to the bare domain (`https://ac.me/`) | Serves a 404 |
+| **Not-found redirect** | Paths that match no alias (`ac.me/tpyo`) | Serves a 404 |
+| **Custom robots.txt** | `ac.me/robots.txt` | Serves a default that blocks all crawlers |
+
+### Root redirect
+
+Where visitors land when they open the bare domain. People will type `ac.me` into a browser out of curiosity; send them somewhere useful with a 302 redirect.
+
+Example: `https://acme.com`, so the domain behind your links leads straight to your website.
+
+### Not-found redirect
+
+The fallback for any path that is not a live short link: typos, expired campaigns, guessed aliases. Instead of a 404, visitors are redirected (302) to the URL you set.
+
+Example: `https://acme.com/links`, a page that lists your active campaigns, so a mistyped link still lands somewhere recoverable.
+
+### Custom robots.txt
+
+The body served at `ac.me/robots.txt`, capped at 4096 characters. By default your domain serves a robots.txt that blocks all crawlers:
+
+```text Default robots.txt
+User-agent: *
+Disallow: /
+```
+
+That default is right for most link domains. Override it if you have a reason, for example to allow specific crawlers.
+
+
+ Short links themselves always send `X-Robots-Tag: noindex, nofollow, noarchive` on their redirects, regardless of robots.txt. A short link is a pure redirect with no content worth indexing, so this is not configurable.
+
+
+## Editing the settings
+
+Change any of the three on the domain page; saving applies immediately. Clearing a field restores its default behavior.
+
+Over the API, `PATCH /api/v1/custom-domains/{id}` is a partial update: fields you omit stay unchanged, and an explicit `null` clears a value back to its default.
+
+
+
+```bash cURL
+# Set both redirects; robots.txt is untouched because it is omitted
+curl -X PATCH https://spoo.me/api/v1/custom-domains/DOMAIN_ID \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "root_redirect": "https://acme.com",
+ "not_found_redirect": "https://acme.com/links"
+ }'
+
+# Clear the not-found redirect (explicit null), back to serving 404s
+curl -X PATCH https://spoo.me/api/v1/custom-domains/DOMAIN_ID \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"not_found_redirect": null}'
+```
+
+```python Python
+import requests
+
+# Set both redirects; robots.txt is untouched because it is omitted
+requests.patch(
+ "https://spoo.me/api/v1/custom-domains/DOMAIN_ID",
+ headers={"Authorization": "Bearer YOUR_API_KEY"},
+ json={
+ "root_redirect": "https://acme.com",
+ "not_found_redirect": "https://acme.com/links",
+ },
+)
+
+# Clear the not-found redirect (explicit null), back to serving 404s
+requests.patch(
+ "https://spoo.me/api/v1/custom-domains/DOMAIN_ID",
+ headers={"Authorization": "Bearer YOUR_API_KEY"},
+ json={"not_found_redirect": None},
+)
+```
+
+
+
+
+ Routing settings are for your own destinations. Pointing the redirects at phishing, malware, or other abusive content gets the domain revoked, which is terminal.
+
diff --git a/dashboard.mdx b/dashboard.mdx
new file mode 100644
index 0000000..9b27c59
--- /dev/null
+++ b/dashboard.mdx
@@ -0,0 +1,105 @@
+---
+title: "Dashboard Tour"
+description: "Find your way around the spoo.me dashboard: Overview, command palette, and keyboard shortcuts"
+icon: "layout-dashboard"
+---
+
+The dashboard is where you create, manage, and measure everything on your account. This page is a quick orientation: what the Overview surfaces, how to move around with the keyboard, and where each feature lives in the docs.
+
+
+ Shortcuts on this page use macOS notation. On Windows and Linux, use `Ctrl` in place of `⌘`.
+
+
+## The Overview page
+
+`/dashboard` opens on **Overview**, a daily briefing across your account: clicks today (with a sparkline), unique visitors today, your last click, and total links, followed by anything that needs your attention.
+
+
+
+
+
+
+| Block | What it shows |
+|---|---|
+| Stat tiles | Clicks today, unique visitors today, last click (relative time), total links |
+| Needs attention | Links that need action, such as a link expiring soon, each with a one-click fix like **Extend** |
+| Hot right now | Your most-clicked links as ranked mini bars, linking through to Analytics |
+| Recent links | Your latest links with destination, age, click count, and a copy button |
+| Workspace | Domain status, connected apps, and API keys at a glance, with shortcuts to manage each |
+
+
+
+
+
+
+New accounts also get a floating **Finish setting up** checklist with four tasks: create your first link, connect a custom domain, create an API key, and install an app. Completed items strike through as you go, and the card collapses out of the way.
+
+
+
+
+
+
+## Command palette
+
+Press `⌘K` anywhere (or click the search button) to open the command palette. It is the fastest route to everything: run commands like **New link** or **Edit dashboard layout**, jump to any page, switch the theme between Light, Dark, and System, or open the API docs.
+
+
+
+
+
+
+Typing searches your links by alias, so the palette doubles as a link finder: type a few characters and jump straight to that link's page.
+
+
+
+
+
+
+## Keyboard shortcuts
+
+Press `?` on any dashboard page to open the shortcuts overlay.
+
+
+
+
+
+
+Navigation uses G-chords: press `G`, then a letter.
+
+| Chord | Goes to |
+|---|---|
+| `G` `O` | Overview |
+| `G` `L` | Links |
+| `G` `A` | Analytics |
+| `G` `D` | Domains |
+| `G` `P` | Apps |
+| `G` `K` | API Keys |
+
+The rest of the global set:
+
+| Shortcut | Does |
+|---|---|
+| `⌘K` | Open the command palette |
+| `⌘B` | Collapse or expand the sidebar |
+| `N` | New link (works on every dashboard page) |
+| `/` | Focus search |
+| `Esc` | Close dialogs and sheets |
+| `?` | This overlay |
+
+Some pages add their own: on Links, `←` and `→` paginate and `⌘A` selects the page; in the link composer, `⌘1` through `⌘4` switch tabs and `⌘↵` creates the link.
+
+## Where everything lives
+
+| Sidebar item | What it is | Docs |
+|---|---|---|
+| Overview | Daily briefing and setup checklist | This page |
+| Links | Your link list: search, filters, bulk actions | [Managing Links](/links/managing-links) |
+| Analytics | Composable widget grid across your account | [Custom Dashboards](/analytics/custom-dashboards) |
+| Domains | Connect and route custom domains | [Custom Domains](/custom-domains) |
+| Apps | Official apps, integrations, and SDKs | [Tools & Libraries](/tools/python-library) |
+| Webhooks | Signed event deliveries | [Webhooks](/webhooks/overview) |
+| API Keys | Scoped keys for the API | [API Keys](/api-keys) |
+
+
+ The **New link** button in the top-right corner (shortcut `N`) is available on every dashboard page. See [Creating Links](/links/creating-links) for the composer walkthrough.
+
diff --git a/docs.json b/docs.json
index 165e0c2..c6c68bf 100644
--- a/docs.json
+++ b/docs.json
@@ -11,26 +11,109 @@
"navigation": {
"tabs": [
{
- "tab": "spoo.me",
+ "tab": "Documentation",
"groups": [
{
"group": "Getting Started",
"pages": [
"introduction",
+ "dashboard",
"quickstart",
"api-keys",
"rate-limits"
]
},
{
- "group": "API v1 Reference",
+ "group": "Links",
+ "pages": [
+ "links/creating-links",
+ "links/managing-links",
+ "links/custom-aliases",
+ "links/emoji-links",
+ "links/password-protection",
+ "links/link-expiration",
+ "links/bulk-operations",
+ "links/geo-targeting",
+ "links/custom-link-previews"
+ ]
+ },
+ {
+ "group": "Custom Domains",
+ "pages": [
+ "custom-domains",
+ "custom-domains/add-a-domain",
+ "custom-domains/dns-and-verification",
+ "custom-domains/create-links",
+ "custom-domains/routing",
+ "custom-domains/removing-a-domain",
+ "custom-domains/api"
+ ]
+ },
+ {
+ "group": "Analytics",
+ "pages": [
+ "analytics/overview",
+ "analytics/link-stats",
+ "analytics/custom-dashboards",
+ "analytics/dimensions",
+ "analytics/public-stats",
+ "analytics/link-previews",
+ "analytics/exports"
+ ]
+ },
+ {
+ "group": "Webhooks",
+ "pages": [
+ "webhooks/overview",
+ "webhooks/event-types",
+ "webhooks/verifying-deliveries",
+ "webhooks/discord-and-slack",
+ "webhooks/managing-deliveries"
+ ]
+ },
+ {
+ "group": "Trust & Safety",
+ "pages": [
+ "trust/reporting-links",
+ "trust/link-safety"
+ ]
+ },
+ {
+ "group": "Tools & Libraries",
+ "pages": [
+ "tools/python-library",
+ "tools/github-action",
+ "tools/spoobot"
+ ]
+ },
+ {
+ "group": "Help",
+ "pages": [
+ "help/troubleshooting"
+ ]
+ }
+ ]
+ },
+ {
+ "tab": "API Reference",
+ "groups": [
+ {
+ "group": "Overview",
+ "pages": [
+ "api-reference/authentication"
+ ]
+ },
+ {
+ "group": "API v1",
"openapi": "openapi-v1.json",
"pages": [
{
"group": "URL Shortening",
"icon": "link",
"pages": [
- "POST /api/v1/shorten"
+ "POST /api/v1/shorten",
+ "GET /api/v1/shorten/check-alias",
+ "GET /api/v1/emoji-set"
]
},
{
@@ -38,9 +121,22 @@
"icon": "list-check",
"pages": [
"GET /api/v1/urls",
+ "GET /api/v1/urls/{url_id}",
+ "GET /api/v1/urls/{domain}/{alias}",
"PATCH /api/v1/urls/{url_id}",
"PATCH /api/v1/urls/{url_id}/status",
- "DELETE /api/v1/urls/{url_id}"
+ "DELETE /api/v1/urls/{url_id}",
+ "DELETE /api/v1/urls"
+ ]
+ },
+ {
+ "group": "Bulk Operations",
+ "icon": "layers",
+ "pages": [
+ "POST /api/v1/urls/bulk/status",
+ "POST /api/v1/urls/bulk/expiry",
+ "POST /api/v1/urls/bulk/domain",
+ "POST /api/v1/urls/bulk/delete"
]
},
{
@@ -50,11 +146,64 @@
"GET /api/v1/stats",
"GET /api/v1/export"
]
+ },
+ {
+ "group": "Public Stats & Preview",
+ "icon": "eye",
+ "pages": [
+ "GET /api/v1/public/stats/{short_code}",
+ "POST /api/v1/public/stats/{short_code}",
+ "GET /api/v1/public/preview/{short_code}"
+ ]
+ },
+ {
+ "group": "Metadata",
+ "icon": "file-search",
+ "pages": [
+ "GET /api/v1/metadata"
+ ]
+ },
+ {
+ "group": "Custom Domains",
+ "icon": "globe",
+ "pages": [
+ "POST /api/v1/custom-domains",
+ "GET /api/v1/custom-domains",
+ "GET /api/v1/custom-domains/{domain_id}",
+ "POST /api/v1/custom-domains/{domain_id}/verify",
+ "PATCH /api/v1/custom-domains/{domain_id}",
+ "DELETE /api/v1/custom-domains/{domain_id}",
+ "DELETE /api/v1/custom-domains/{domain_id}/permanent"
+ ]
+ },
+ {
+ "group": "Webhooks",
+ "icon": "webhook",
+ "pages": [
+ "GET /api/v1/webhooks/event-types",
+ "POST /api/v1/webhooks",
+ "GET /api/v1/webhooks",
+ "GET /api/v1/webhooks/{endpoint_id}",
+ "PATCH /api/v1/webhooks/{endpoint_id}",
+ "DELETE /api/v1/webhooks/{endpoint_id}",
+ "GET /api/v1/webhooks/{endpoint_id}/secret",
+ "POST /api/v1/webhooks/{endpoint_id}/test",
+ "GET /api/v1/webhooks/{endpoint_id}/deliveries",
+ "POST /api/v1/webhooks/{endpoint_id}/deliveries/{delivery_id}/retry"
+ ]
+ },
+ {
+ "group": "Reports & Contact",
+ "icon": "flag",
+ "pages": [
+ "POST /api/v1/reports",
+ "POST /api/v1/contact"
+ ]
}
]
},
{
- "group": "API v0 Reference (Legacy)",
+ "group": "API v0 (Legacy)",
"openapi": "openapi-v0.json",
"pages": [
{
@@ -80,32 +229,37 @@
]
}
]
- },
+ }
+ ]
+ },
+ {
+ "tab": "Self-Hosting",
+ "groups": [
{
- "group": "Tools & Libraries",
+ "group": "Overview",
"pages": [
- "tools/python-library",
- "tools/github-action",
- "tools/spoobot"
+ "self-hosting/introduction",
+ "self-hosting/configuration"
]
},
{
- "group": "Self Hosting",
+ "group": "Setting Up",
"pages": [
- "self-hosting/introduction",
"self-hosting/setting-up-mongo",
"self-hosting/setting-up-redis",
"self-hosting/setting-up-authentication",
- "self-hosting/creating-discord-webhooks",
- {
- "group": "Deployment",
- "icon": "server",
- "pages": [
- "self-hosting/cloud-deployment",
- "self-hosting/docker-deployment",
- "self-hosting/local-development"
- ]
- }
+ "self-hosting/creating-discord-webhooks"
+ ]
+ },
+ {
+ "group": "Deploy",
+ "pages": [
+ "self-hosting/deploy-vercel",
+ "self-hosting/deploy-railway",
+ "self-hosting/deploy-render",
+ "self-hosting/deploy-koyeb",
+ "self-hosting/docker-deployment",
+ "self-hosting/local-development"
]
}
]
@@ -152,6 +306,20 @@
}
]
},
+ "redirects": [
+ {
+ "source": "/self-hosting/cloud-deployment",
+ "destination": "/self-hosting/deploy-vercel"
+ },
+ {
+ "source": "/emoji-urls",
+ "destination": "/links/emoji-links"
+ },
+ {
+ "source": "/getting-started/introduction",
+ "destination": "/introduction"
+ }
+ ],
"logo": {
"light": "/images/logo/logo-full-light.png",
"dark": "/images/logo/logo-full-dark.png",
@@ -185,4 +353,4 @@
"topbar": {
"style": "gradient"
}
-}
+}
\ No newline at end of file
diff --git a/help/troubleshooting.mdx b/help/troubleshooting.mdx
new file mode 100644
index 0000000..1126edc
--- /dev/null
+++ b/help/troubleshooting.mdx
@@ -0,0 +1,140 @@
+---
+title: "Troubleshooting"
+description: "Fixes for the most common Spoo.me errors, from verification gates to rate limits"
+icon: "life-buoy"
+---
+
+Find your symptom below. Each entry explains what triggered the error and how to fix it.
+
+
+
+ **Symptom**: Creating a link, checking an alias, creating an API key, registering a custom domain, or managing webhooks fails with a `403` response and the code `EMAIL_NOT_VERIFIED`.
+
+ **Cause**: These actions create durable resources, so they are blocked for signed-in accounts that have not verified their email address.
+
+ **Fix**:
+
+ 1. Check the inbox of the email you signed up with for the verification code.
+ 2. If you cannot find it, sign in at [spoo.me](https://spoo.me) and request a new verification email from the banner or account settings.
+ 3. Resends are limited to 1 per minute and 3 per hour, so wait before requesting again.
+ 4. Enter the code, then retry the request. No other change is needed.
+
+ Note that anonymous link creation is unaffected. This gate only applies when you are signed in or using an API key tied to an unverified account.
+
+
+
+ **Symptom**: Requests fail with status `429` and this body:
+
+ ```json
+ {
+ "error": "Too many requests",
+ "code": "rate_limit_exceeded"
+ }
+ ```
+
+ **Cause**: You exceeded the budget for that endpoint, tracked per API key, per session, or per IP address for anonymous requests.
+
+ **Fix**:
+
+ 1. Back off and retry with increasing delays. The API does not send `X-RateLimit-*` headers, so pace your client against the published [rate limits](/rate-limits).
+ 2. Authenticate your requests. Signed-in and API-key callers get 3x the per-minute budget and 5x the daily budget on core endpoints.
+ 3. Batch where possible: bulk endpoints count one request regardless of how many links it touches.
+ 4. If your use case genuinely needs more, email [support@spoo.me](mailto:support@spoo.me) with your expected volume.
+
+
+
+ **Symptom**: Creating a link with a custom alias fails, or the alias checker reports the name is unavailable.
+
+ **Cause**: The alias failed one of the creation rules: too long, invalid characters, a reserved name, an emoji sequence outside the accepted set, or simply taken by an existing link.
+
+ **Fix**:
+
+ 1. Use `GET /api/v1/shorten/check-alias` (or the live checker in the link composer) to see the exact reason: `length`, `format`, `reserved`, `taken`, or `emoji_policy`.
+ 2. **Taken**: someone already owns that alias on that domain. Pick another name.
+ 3. **Reserved**: names matching Spoo.me product paths (`about`, `api`, `dashboard`, `docs`, and similar) can never be issued on `spoo.me`, because a link there would be shadowed by the site itself. Reserved names are perfectly legal on your own [custom domain](/custom-domains).
+ 4. **Format or length**: stick to the allowed characters and length shown in the composer.
+ 5. **Emoji policy**: emoji aliases must use emojis from the accepted set, available at `GET /api/v1/emoji-set`.
+
+
+
+ **Symptom**: Your domain never flips to Active after you added the DNS records, or a previously working domain shows Suspended and its links stopped redirecting.
+
+ **Cause**: Verification cannot see the required DNS records, most often because they have not propagated yet or because the CNAME is being proxied.
+
+ **Fix**:
+
+ 1. Open the domain in the dashboard and compare your DNS records against the exact CNAME and TXT values shown there. Both must exist, at the exact names given.
+ 2. **If your DNS is on Cloudflare**: set the CNAME to **DNS only** (grey cloud icon), not Proxied (orange cloud). A proxied record hides the target and verification will fail.
+ 3. Wait for propagation. DNS changes can take minutes to hours depending on your provider's TTL.
+ 4. Run **Verify** again from the dashboard. Verification attempts are limited (10 per minute, with an hourly cap per domain), so fix the records before retrying in a loop.
+ 5. **Suspended** means periodic re-verification found the records missing or changed. Restore the original records, then verify again to reactivate. Your links and their stats are kept while the domain is suspended.
+
+
+
+ **Symptom**: Signing in from a CLI, browser extension, or desktop app fails with a message like: "This app's sign-in request is missing a valid security challenge (PKCE). Please update the app to its latest version and try again."
+
+ **Cause**: Connected apps now must prove possession of the sign-in request with PKCE, and the version you are running predates that requirement.
+
+ **Fix**:
+
+ 1. Update the app to its latest version (reinstall the CLI, update the extension from its store, or pull the latest release).
+ 2. Retry the sign-in. No account changes are needed; only the client was out of date.
+
+
+
+ **Symptom**: You need an API key you created earlier, but the dashboard only shows its name and prefix, never the full token.
+
+ **Cause**: The full key is displayed exactly once, at creation. It is stored hashed and cannot be recovered afterward, by you or by Spoo.me.
+
+ **Fix**:
+
+ 1. Create a new key from the [API keys page](https://spoo.me/dashboard/keys) with the same scopes.
+ 2. Copy the token immediately and store it in a secrets manager or environment variable.
+ 3. Delete the lost key so it cannot sit unused with live permissions.
+
+ Key creation is limited to 5 per hour and always requires being signed in on the dashboard. See the [API keys guide](/api-keys) for storage practices.
+
+
+
+ **Symptom**: Opening the stats page for a short link returns 404, even though the link itself redirects fine.
+
+ **Cause**: Links with private stats intentionally answer exactly like a nonexistent link, so outsiders cannot tell a private link from no link at all.
+
+ **Fix**:
+
+ 1. If it is your link, sign in. The owner's session bypasses the privacy gate and the password gate on the public stats endpoint, and the dashboard always shows your own analytics.
+ 2. If it is someone else's link, there is nothing to fix: the owner has made its stats private, and 404 is the intended answer.
+ 3. If the link is password-protected, the stats endpoint returns `401` with code `password_required`. Submit the password via POST, never in the URL.
+
+
+
+ **Symptom**: A link you created works, but it does not appear in your dashboard and the API returns 404 when you try to update or delete it.
+
+ **Cause**: Links created anonymously have no owner, and ownership is assigned only at creation time, so no account can ever manage them.
+
+ **Fix**:
+
+ 1. Recreate the link while signed in (or with an API key) so it is owned by your account, and share the new short URL.
+ 2. Going forward, authenticate every request that creates links you may want to edit, expire, or delete later.
+ 3. An anonymous link that violates policy or needs removal can be reported via the report page, but it cannot be claimed.
+
+
+
+## Still stuck?
+
+
+
+ The fastest way to get help from the team and community
+
+
+ Include the failing request, the response body, and a timestamp
+
+
diff --git a/images/account/api-keys-create-dark.png b/images/account/api-keys-create-dark.png
new file mode 100644
index 0000000..3994465
Binary files /dev/null and b/images/account/api-keys-create-dark.png differ
diff --git a/images/account/api-keys-create-light.png b/images/account/api-keys-create-light.png
new file mode 100644
index 0000000..ff62ed1
Binary files /dev/null and b/images/account/api-keys-create-light.png differ
diff --git a/images/account/api-keys-page-dark.png b/images/account/api-keys-page-dark.png
new file mode 100644
index 0000000..36ed60b
Binary files /dev/null and b/images/account/api-keys-page-dark.png differ
diff --git a/images/account/api-keys-page-light.png b/images/account/api-keys-page-light.png
new file mode 100644
index 0000000..3c11082
Binary files /dev/null and b/images/account/api-keys-page-light.png differ
diff --git a/images/account/connected-apps-dark.png b/images/account/connected-apps-dark.png
new file mode 100644
index 0000000..c277807
Binary files /dev/null and b/images/account/connected-apps-dark.png differ
diff --git a/images/account/connected-apps-light.png b/images/account/connected-apps-light.png
new file mode 100644
index 0000000..5512460
Binary files /dev/null and b/images/account/connected-apps-light.png differ
diff --git a/images/account/profile-settings-dark.png b/images/account/profile-settings-dark.png
new file mode 100644
index 0000000..6bb8942
Binary files /dev/null and b/images/account/profile-settings-dark.png differ
diff --git a/images/account/profile-settings-light.png b/images/account/profile-settings-light.png
new file mode 100644
index 0000000..1ce7318
Binary files /dev/null and b/images/account/profile-settings-light.png differ
diff --git a/images/analytics/dashboard-widgets-dark.png b/images/analytics/dashboard-widgets-dark.png
new file mode 100644
index 0000000..11ec631
Binary files /dev/null and b/images/analytics/dashboard-widgets-dark.png differ
diff --git a/images/analytics/dashboard-widgets-light.png b/images/analytics/dashboard-widgets-light.png
new file mode 100644
index 0000000..43ea534
Binary files /dev/null and b/images/analytics/dashboard-widgets-light.png differ
diff --git a/images/analytics/link-preview-page-dark.png b/images/analytics/link-preview-page-dark.png
new file mode 100644
index 0000000..7b3e7bb
Binary files /dev/null and b/images/analytics/link-preview-page-dark.png differ
diff --git a/images/analytics/link-preview-page-light.png b/images/analytics/link-preview-page-light.png
new file mode 100644
index 0000000..3d2c76a
Binary files /dev/null and b/images/analytics/link-preview-page-light.png differ
diff --git a/images/analytics/public-stats-page-dark.png b/images/analytics/public-stats-page-dark.png
new file mode 100644
index 0000000..e743cef
Binary files /dev/null and b/images/analytics/public-stats-page-dark.png differ
diff --git a/images/analytics/public-stats-page-light.png b/images/analytics/public-stats-page-light.png
new file mode 100644
index 0000000..49b6d9c
Binary files /dev/null and b/images/analytics/public-stats-page-light.png differ
diff --git a/images/custom-domains/add-domain-dark.png b/images/custom-domains/add-domain-dark.png
new file mode 100644
index 0000000..734aab7
Binary files /dev/null and b/images/custom-domains/add-domain-dark.png differ
diff --git a/images/custom-domains/add-domain-light.png b/images/custom-domains/add-domain-light.png
new file mode 100644
index 0000000..c0492b1
Binary files /dev/null and b/images/custom-domains/add-domain-light.png differ
diff --git a/images/custom-domains/dns-records-dark.png b/images/custom-domains/dns-records-dark.png
new file mode 100644
index 0000000..b40af1e
Binary files /dev/null and b/images/custom-domains/dns-records-dark.png differ
diff --git a/images/custom-domains/dns-records-light.png b/images/custom-domains/dns-records-light.png
new file mode 100644
index 0000000..c3c7f66
Binary files /dev/null and b/images/custom-domains/dns-records-light.png differ
diff --git a/images/custom-domains/domain-active-dark.png b/images/custom-domains/domain-active-dark.png
new file mode 100644
index 0000000..837c129
Binary files /dev/null and b/images/custom-domains/domain-active-dark.png differ
diff --git a/images/custom-domains/domain-active-light.png b/images/custom-domains/domain-active-light.png
new file mode 100644
index 0000000..3511b95
Binary files /dev/null and b/images/custom-domains/domain-active-light.png differ
diff --git a/images/custom-domains/domains-list-dark.png b/images/custom-domains/domains-list-dark.png
new file mode 100644
index 0000000..e2535ca
Binary files /dev/null and b/images/custom-domains/domains-list-dark.png differ
diff --git a/images/custom-domains/domains-list-light.png b/images/custom-domains/domains-list-light.png
new file mode 100644
index 0000000..5565f3b
Binary files /dev/null and b/images/custom-domains/domains-list-light.png differ
diff --git a/images/custom-domains/move-link-dark.png b/images/custom-domains/move-link-dark.png
new file mode 100644
index 0000000..87318d7
Binary files /dev/null and b/images/custom-domains/move-link-dark.png differ
diff --git a/images/custom-domains/move-link-light.png b/images/custom-domains/move-link-light.png
new file mode 100644
index 0000000..098ac3e
Binary files /dev/null and b/images/custom-domains/move-link-light.png differ
diff --git a/images/custom-domains/new-link-on-domain-dark.png b/images/custom-domains/new-link-on-domain-dark.png
new file mode 100644
index 0000000..3ebf421
Binary files /dev/null and b/images/custom-domains/new-link-on-domain-dark.png differ
diff --git a/images/custom-domains/new-link-on-domain-light.png b/images/custom-domains/new-link-on-domain-light.png
new file mode 100644
index 0000000..76fb4c6
Binary files /dev/null and b/images/custom-domains/new-link-on-domain-light.png differ
diff --git a/images/custom-domains/revoke-confirm-dark.png b/images/custom-domains/revoke-confirm-dark.png
new file mode 100644
index 0000000..533bace
Binary files /dev/null and b/images/custom-domains/revoke-confirm-dark.png differ
diff --git a/images/custom-domains/revoke-confirm-light.png b/images/custom-domains/revoke-confirm-light.png
new file mode 100644
index 0000000..94836b7
Binary files /dev/null and b/images/custom-domains/revoke-confirm-light.png differ
diff --git a/images/dashboard/account-menu-dark.png b/images/dashboard/account-menu-dark.png
new file mode 100644
index 0000000..c157539
Binary files /dev/null and b/images/dashboard/account-menu-dark.png differ
diff --git a/images/dashboard/account-menu-light.png b/images/dashboard/account-menu-light.png
new file mode 100644
index 0000000..ff84ba2
Binary files /dev/null and b/images/dashboard/account-menu-light.png differ
diff --git a/images/dashboard/analytics-add-widget-dark.png b/images/dashboard/analytics-add-widget-dark.png
new file mode 100644
index 0000000..ebce07d
Binary files /dev/null and b/images/dashboard/analytics-add-widget-dark.png differ
diff --git a/images/dashboard/analytics-add-widget-light.png b/images/dashboard/analytics-add-widget-light.png
new file mode 100644
index 0000000..61889ca
Binary files /dev/null and b/images/dashboard/analytics-add-widget-light.png differ
diff --git a/images/dashboard/analytics-custom-chart-configured-dark.png b/images/dashboard/analytics-custom-chart-configured-dark.png
new file mode 100644
index 0000000..7feb330
Binary files /dev/null and b/images/dashboard/analytics-custom-chart-configured-dark.png differ
diff --git a/images/dashboard/analytics-custom-chart-configured-light.png b/images/dashboard/analytics-custom-chart-configured-light.png
new file mode 100644
index 0000000..c0d54ad
Binary files /dev/null and b/images/dashboard/analytics-custom-chart-configured-light.png differ
diff --git a/images/dashboard/analytics-custom-chart-dark.png b/images/dashboard/analytics-custom-chart-dark.png
new file mode 100644
index 0000000..dcb3c18
Binary files /dev/null and b/images/dashboard/analytics-custom-chart-dark.png differ
diff --git a/images/dashboard/analytics-custom-chart-light.png b/images/dashboard/analytics-custom-chart-light.png
new file mode 100644
index 0000000..9e0be31
Binary files /dev/null and b/images/dashboard/analytics-custom-chart-light.png differ
diff --git a/images/dashboard/analytics-custom-widget-added-dark.png b/images/dashboard/analytics-custom-widget-added-dark.png
new file mode 100644
index 0000000..e4b8ac4
Binary files /dev/null and b/images/dashboard/analytics-custom-widget-added-dark.png differ
diff --git a/images/dashboard/analytics-custom-widget-added-light.png b/images/dashboard/analytics-custom-widget-added-light.png
new file mode 100644
index 0000000..754aa55
Binary files /dev/null and b/images/dashboard/analytics-custom-widget-added-light.png differ
diff --git a/images/dashboard/analytics-dark.png b/images/dashboard/analytics-dark.png
new file mode 100644
index 0000000..5b022f5
Binary files /dev/null and b/images/dashboard/analytics-dark.png differ
diff --git a/images/dashboard/analytics-edit-mode-dark.png b/images/dashboard/analytics-edit-mode-dark.png
new file mode 100644
index 0000000..6c15427
Binary files /dev/null and b/images/dashboard/analytics-edit-mode-dark.png differ
diff --git a/images/dashboard/analytics-edit-mode-light.png b/images/dashboard/analytics-edit-mode-light.png
new file mode 100644
index 0000000..2a822ec
Binary files /dev/null and b/images/dashboard/analytics-edit-mode-light.png differ
diff --git a/images/dashboard/analytics-filtered-dark.png b/images/dashboard/analytics-filtered-dark.png
new file mode 100644
index 0000000..a9deee7
Binary files /dev/null and b/images/dashboard/analytics-filtered-dark.png differ
diff --git a/images/dashboard/analytics-filtered-light.png b/images/dashboard/analytics-filtered-light.png
new file mode 100644
index 0000000..eb8b1cf
Binary files /dev/null and b/images/dashboard/analytics-filtered-light.png differ
diff --git a/images/dashboard/analytics-layout-menu-dark.png b/images/dashboard/analytics-layout-menu-dark.png
new file mode 100644
index 0000000..c3fce5a
Binary files /dev/null and b/images/dashboard/analytics-layout-menu-dark.png differ
diff --git a/images/dashboard/analytics-layout-menu-light.png b/images/dashboard/analytics-layout-menu-light.png
new file mode 100644
index 0000000..330a6d4
Binary files /dev/null and b/images/dashboard/analytics-layout-menu-light.png differ
diff --git a/images/dashboard/analytics-light.png b/images/dashboard/analytics-light.png
new file mode 100644
index 0000000..0a41039
Binary files /dev/null and b/images/dashboard/analytics-light.png differ
diff --git a/images/dashboard/analytics-pinned-widget-dark.png b/images/dashboard/analytics-pinned-widget-dark.png
new file mode 100644
index 0000000..90475e4
Binary files /dev/null and b/images/dashboard/analytics-pinned-widget-dark.png differ
diff --git a/images/dashboard/analytics-pinned-widget-light.png b/images/dashboard/analytics-pinned-widget-light.png
new file mode 100644
index 0000000..cb41a71
Binary files /dev/null and b/images/dashboard/analytics-pinned-widget-light.png differ
diff --git a/images/dashboard/analytics-widget-expanded-dark.png b/images/dashboard/analytics-widget-expanded-dark.png
new file mode 100644
index 0000000..e9719f3
Binary files /dev/null and b/images/dashboard/analytics-widget-expanded-dark.png differ
diff --git a/images/dashboard/analytics-widget-expanded-light.png b/images/dashboard/analytics-widget-expanded-light.png
new file mode 100644
index 0000000..7cad835
Binary files /dev/null and b/images/dashboard/analytics-widget-expanded-light.png differ
diff --git a/images/dashboard/analytics-widget-resized-dark.png b/images/dashboard/analytics-widget-resized-dark.png
new file mode 100644
index 0000000..a9d55e1
Binary files /dev/null and b/images/dashboard/analytics-widget-resized-dark.png differ
diff --git a/images/dashboard/analytics-widget-resized-light.png b/images/dashboard/analytics-widget-resized-light.png
new file mode 100644
index 0000000..5571f5a
Binary files /dev/null and b/images/dashboard/analytics-widget-resized-light.png differ
diff --git a/images/dashboard/api-key-created-dark.png b/images/dashboard/api-key-created-dark.png
new file mode 100644
index 0000000..17fe60e
Binary files /dev/null and b/images/dashboard/api-key-created-dark.png differ
diff --git a/images/dashboard/api-keys-create-dark.png b/images/dashboard/api-keys-create-dark.png
new file mode 100644
index 0000000..e5ca9f4
Binary files /dev/null and b/images/dashboard/api-keys-create-dark.png differ
diff --git a/images/dashboard/api-keys-create-light.png b/images/dashboard/api-keys-create-light.png
new file mode 100644
index 0000000..bb0eac5
Binary files /dev/null and b/images/dashboard/api-keys-create-light.png differ
diff --git a/images/dashboard/api-keys-dark.png b/images/dashboard/api-keys-dark.png
new file mode 100644
index 0000000..30fcb1f
Binary files /dev/null and b/images/dashboard/api-keys-dark.png differ
diff --git a/images/dashboard/api-keys-list-dark.png b/images/dashboard/api-keys-list-dark.png
new file mode 100644
index 0000000..be48bbf
Binary files /dev/null and b/images/dashboard/api-keys-list-dark.png differ
diff --git a/images/dashboard/api-keys-list-light.png b/images/dashboard/api-keys-list-light.png
new file mode 100644
index 0000000..1bf2319
Binary files /dev/null and b/images/dashboard/api-keys-list-light.png differ
diff --git a/images/dashboard/apps-dark.png b/images/dashboard/apps-dark.png
new file mode 100644
index 0000000..1aee335
Binary files /dev/null and b/images/dashboard/apps-dark.png differ
diff --git a/images/dashboard/apps-detail-dark.png b/images/dashboard/apps-detail-dark.png
new file mode 100644
index 0000000..efb1e06
Binary files /dev/null and b/images/dashboard/apps-detail-dark.png differ
diff --git a/images/dashboard/apps-detail-light.png b/images/dashboard/apps-detail-light.png
new file mode 100644
index 0000000..7a58708
Binary files /dev/null and b/images/dashboard/apps-detail-light.png differ
diff --git a/images/dashboard/apps-light.png b/images/dashboard/apps-light.png
new file mode 100644
index 0000000..e2d07fa
Binary files /dev/null and b/images/dashboard/apps-light.png differ
diff --git a/images/dashboard/domain-detail-dark.png b/images/dashboard/domain-detail-dark.png
new file mode 100644
index 0000000..37e9fed
Binary files /dev/null and b/images/dashboard/domain-detail-dark.png differ
diff --git a/images/dashboard/domain-detail-light.png b/images/dashboard/domain-detail-light.png
new file mode 100644
index 0000000..e1ab00b
Binary files /dev/null and b/images/dashboard/domain-detail-light.png differ
diff --git a/images/dashboard/global-search-dark.png b/images/dashboard/global-search-dark.png
new file mode 100644
index 0000000..7bf75fe
Binary files /dev/null and b/images/dashboard/global-search-dark.png differ
diff --git a/images/dashboard/global-search-light.png b/images/dashboard/global-search-light.png
new file mode 100644
index 0000000..7d1fa7f
Binary files /dev/null and b/images/dashboard/global-search-light.png differ
diff --git a/images/dashboard/global-search-results-dark.png b/images/dashboard/global-search-results-dark.png
new file mode 100644
index 0000000..24d7692
Binary files /dev/null and b/images/dashboard/global-search-results-dark.png differ
diff --git a/images/dashboard/global-search-results-light.png b/images/dashboard/global-search-results-light.png
new file mode 100644
index 0000000..44ed454
Binary files /dev/null and b/images/dashboard/global-search-results-light.png differ
diff --git a/images/dashboard/keyboard-shortcuts-dark.png b/images/dashboard/keyboard-shortcuts-dark.png
new file mode 100644
index 0000000..c9f8304
Binary files /dev/null and b/images/dashboard/keyboard-shortcuts-dark.png differ
diff --git a/images/dashboard/keyboard-shortcuts-light.png b/images/dashboard/keyboard-shortcuts-light.png
new file mode 100644
index 0000000..90ca3ba
Binary files /dev/null and b/images/dashboard/keyboard-shortcuts-light.png differ
diff --git a/images/dashboard/link-detail-dark.png b/images/dashboard/link-detail-dark.png
new file mode 100644
index 0000000..bbe6fd0
Binary files /dev/null and b/images/dashboard/link-detail-dark.png differ
diff --git a/images/dashboard/link-detail-light.png b/images/dashboard/link-detail-light.png
new file mode 100644
index 0000000..9e28e84
Binary files /dev/null and b/images/dashboard/link-detail-light.png differ
diff --git a/images/dashboard/link-detail-settings-dark.png b/images/dashboard/link-detail-settings-dark.png
new file mode 100644
index 0000000..5c19ad9
Binary files /dev/null and b/images/dashboard/link-detail-settings-dark.png differ
diff --git a/images/dashboard/link-detail-settings-light.png b/images/dashboard/link-detail-settings-light.png
new file mode 100644
index 0000000..4b6e441
Binary files /dev/null and b/images/dashboard/link-detail-settings-light.png differ
diff --git a/images/dashboard/link-detail-settings-meta-dark.png b/images/dashboard/link-detail-settings-meta-dark.png
new file mode 100644
index 0000000..753baab
Binary files /dev/null and b/images/dashboard/link-detail-settings-meta-dark.png differ
diff --git a/images/dashboard/link-detail-settings-meta-light.png b/images/dashboard/link-detail-settings-meta-light.png
new file mode 100644
index 0000000..48a1fec
Binary files /dev/null and b/images/dashboard/link-detail-settings-meta-light.png differ
diff --git a/images/dashboard/link-peek-dark.png b/images/dashboard/link-peek-dark.png
new file mode 100644
index 0000000..8b39a02
Binary files /dev/null and b/images/dashboard/link-peek-dark.png differ
diff --git a/images/dashboard/link-peek-light.png b/images/dashboard/link-peek-light.png
new file mode 100644
index 0000000..dc6e452
Binary files /dev/null and b/images/dashboard/link-peek-light.png differ
diff --git a/images/dashboard/links-bulk-actions-dark.png b/images/dashboard/links-bulk-actions-dark.png
new file mode 100644
index 0000000..fdb33a0
Binary files /dev/null and b/images/dashboard/links-bulk-actions-dark.png differ
diff --git a/images/dashboard/links-bulk-actions-light.png b/images/dashboard/links-bulk-actions-light.png
new file mode 100644
index 0000000..9f8b9a5
Binary files /dev/null and b/images/dashboard/links-bulk-actions-light.png differ
diff --git a/images/dashboard/links-bulk-select-dark.png b/images/dashboard/links-bulk-select-dark.png
new file mode 100644
index 0000000..fc1f7c7
Binary files /dev/null and b/images/dashboard/links-bulk-select-dark.png differ
diff --git a/images/dashboard/links-bulk-select-light.png b/images/dashboard/links-bulk-select-light.png
new file mode 100644
index 0000000..165842b
Binary files /dev/null and b/images/dashboard/links-bulk-select-light.png differ
diff --git a/images/dashboard/links-daterange-dark.png b/images/dashboard/links-daterange-dark.png
new file mode 100644
index 0000000..94305dd
Binary files /dev/null and b/images/dashboard/links-daterange-dark.png differ
diff --git a/images/dashboard/links-daterange-light.png b/images/dashboard/links-daterange-light.png
new file mode 100644
index 0000000..08d8bfc
Binary files /dev/null and b/images/dashboard/links-daterange-light.png differ
diff --git a/images/dashboard/links-filters-dark.png b/images/dashboard/links-filters-dark.png
new file mode 100644
index 0000000..df9beab
Binary files /dev/null and b/images/dashboard/links-filters-dark.png differ
diff --git a/images/dashboard/links-filters-light.png b/images/dashboard/links-filters-light.png
new file mode 100644
index 0000000..e47e924
Binary files /dev/null and b/images/dashboard/links-filters-light.png differ
diff --git a/images/dashboard/links-list-dark.png b/images/dashboard/links-list-dark.png
new file mode 100644
index 0000000..f708fee
Binary files /dev/null and b/images/dashboard/links-list-dark.png differ
diff --git a/images/dashboard/links-list-light.png b/images/dashboard/links-list-light.png
new file mode 100644
index 0000000..3283ca0
Binary files /dev/null and b/images/dashboard/links-list-light.png differ
diff --git a/images/dashboard/links-move-domain-dark.png b/images/dashboard/links-move-domain-dark.png
new file mode 100644
index 0000000..3957687
Binary files /dev/null and b/images/dashboard/links-move-domain-dark.png differ
diff --git a/images/dashboard/links-move-domain-light.png b/images/dashboard/links-move-domain-light.png
new file mode 100644
index 0000000..bae5f88
Binary files /dev/null and b/images/dashboard/links-move-domain-light.png differ
diff --git a/images/dashboard/links-refresh-menu-dark.png b/images/dashboard/links-refresh-menu-dark.png
new file mode 100644
index 0000000..4b0d870
Binary files /dev/null and b/images/dashboard/links-refresh-menu-dark.png differ
diff --git a/images/dashboard/links-refresh-menu-light.png b/images/dashboard/links-refresh-menu-light.png
new file mode 100644
index 0000000..449253a
Binary files /dev/null and b/images/dashboard/links-refresh-menu-light.png differ
diff --git a/images/dashboard/links-row-actions-dark.png b/images/dashboard/links-row-actions-dark.png
new file mode 100644
index 0000000..54722bc
Binary files /dev/null and b/images/dashboard/links-row-actions-dark.png differ
diff --git a/images/dashboard/links-row-actions-light.png b/images/dashboard/links-row-actions-light.png
new file mode 100644
index 0000000..ed6e531
Binary files /dev/null and b/images/dashboard/links-row-actions-light.png differ
diff --git a/images/dashboard/new-link-alias-suggest-dark.png b/images/dashboard/new-link-alias-suggest-dark.png
new file mode 100644
index 0000000..d47c654
Binary files /dev/null and b/images/dashboard/new-link-alias-suggest-dark.png differ
diff --git a/images/dashboard/new-link-alias-suggest-light.png b/images/dashboard/new-link-alias-suggest-light.png
new file mode 100644
index 0000000..36cbbaa
Binary files /dev/null and b/images/dashboard/new-link-alias-suggest-light.png differ
diff --git a/images/dashboard/new-link-basic-dark.png b/images/dashboard/new-link-basic-dark.png
new file mode 100644
index 0000000..5d34c58
Binary files /dev/null and b/images/dashboard/new-link-basic-dark.png differ
diff --git a/images/dashboard/new-link-basic-light.png b/images/dashboard/new-link-basic-light.png
new file mode 100644
index 0000000..6b6f380
Binary files /dev/null and b/images/dashboard/new-link-basic-light.png differ
diff --git a/images/dashboard/new-link-domain-picker-dark.png b/images/dashboard/new-link-domain-picker-dark.png
new file mode 100644
index 0000000..ba26a9d
Binary files /dev/null and b/images/dashboard/new-link-domain-picker-dark.png differ
diff --git a/images/dashboard/new-link-domain-picker-light.png b/images/dashboard/new-link-domain-picker-light.png
new file mode 100644
index 0000000..8c7e95d
Binary files /dev/null and b/images/dashboard/new-link-domain-picker-light.png differ
diff --git a/images/dashboard/new-link-emoji-browser-dark.png b/images/dashboard/new-link-emoji-browser-dark.png
new file mode 100644
index 0000000..13e6921
Binary files /dev/null and b/images/dashboard/new-link-emoji-browser-dark.png differ
diff --git a/images/dashboard/new-link-emoji-browser-light.png b/images/dashboard/new-link-emoji-browser-light.png
new file mode 100644
index 0000000..af71ae1
Binary files /dev/null and b/images/dashboard/new-link-emoji-browser-light.png differ
diff --git a/images/dashboard/new-link-metadata-dark.png b/images/dashboard/new-link-metadata-dark.png
new file mode 100644
index 0000000..34d49f6
Binary files /dev/null and b/images/dashboard/new-link-metadata-dark.png differ
diff --git a/images/dashboard/new-link-metadata-light.png b/images/dashboard/new-link-metadata-light.png
new file mode 100644
index 0000000..90524f0
Binary files /dev/null and b/images/dashboard/new-link-metadata-light.png differ
diff --git a/images/dashboard/new-link-security-dark.png b/images/dashboard/new-link-security-dark.png
new file mode 100644
index 0000000..f252d74
Binary files /dev/null and b/images/dashboard/new-link-security-dark.png differ
diff --git a/images/dashboard/new-link-security-light.png b/images/dashboard/new-link-security-light.png
new file mode 100644
index 0000000..dc3f54e
Binary files /dev/null and b/images/dashboard/new-link-security-light.png differ
diff --git a/images/dashboard/new-link-targeting-dark.png b/images/dashboard/new-link-targeting-dark.png
new file mode 100644
index 0000000..f99b235
Binary files /dev/null and b/images/dashboard/new-link-targeting-dark.png differ
diff --git a/images/dashboard/new-link-targeting-light.png b/images/dashboard/new-link-targeting-light.png
new file mode 100644
index 0000000..ab9dd8b
Binary files /dev/null and b/images/dashboard/new-link-targeting-light.png differ
diff --git a/images/dashboard/overview-dark.png b/images/dashboard/overview-dark.png
new file mode 100644
index 0000000..8b5d14b
Binary files /dev/null and b/images/dashboard/overview-dark.png differ
diff --git a/images/dashboard/overview-light.png b/images/dashboard/overview-light.png
new file mode 100644
index 0000000..dd18425
Binary files /dev/null and b/images/dashboard/overview-light.png differ
diff --git a/images/dashboard/overview-needs-attention-dark.png b/images/dashboard/overview-needs-attention-dark.png
new file mode 100644
index 0000000..b5dd601
Binary files /dev/null and b/images/dashboard/overview-needs-attention-dark.png differ
diff --git a/images/dashboard/overview-needs-attention-light.png b/images/dashboard/overview-needs-attention-light.png
new file mode 100644
index 0000000..11a790b
Binary files /dev/null and b/images/dashboard/overview-needs-attention-light.png differ
diff --git a/images/dashboard/settings-avatar-menu-dark.png b/images/dashboard/settings-avatar-menu-dark.png
new file mode 100644
index 0000000..1290b5d
Binary files /dev/null and b/images/dashboard/settings-avatar-menu-dark.png differ
diff --git a/images/dashboard/settings-avatar-menu-light.png b/images/dashboard/settings-avatar-menu-light.png
new file mode 100644
index 0000000..729de0a
Binary files /dev/null and b/images/dashboard/settings-avatar-menu-light.png differ
diff --git a/images/dashboard/settings-dark.png b/images/dashboard/settings-dark.png
new file mode 100644
index 0000000..8d47916
Binary files /dev/null and b/images/dashboard/settings-dark.png differ
diff --git a/images/dashboard/settings-light.png b/images/dashboard/settings-light.png
new file mode 100644
index 0000000..df9cc2a
Binary files /dev/null and b/images/dashboard/settings-light.png differ
diff --git a/images/dashboard/setup-checklist-dark.png b/images/dashboard/setup-checklist-dark.png
new file mode 100644
index 0000000..520fd71
Binary files /dev/null and b/images/dashboard/setup-checklist-dark.png differ
diff --git a/images/dashboard/setup-checklist-light.png b/images/dashboard/setup-checklist-light.png
new file mode 100644
index 0000000..a961e34
Binary files /dev/null and b/images/dashboard/setup-checklist-light.png differ
diff --git a/images/dashboard/webhook-delivery-detail-dark.png b/images/dashboard/webhook-delivery-detail-dark.png
new file mode 100644
index 0000000..9cf9339
Binary files /dev/null and b/images/dashboard/webhook-delivery-detail-dark.png differ
diff --git a/images/dashboard/webhook-delivery-detail-light.png b/images/dashboard/webhook-delivery-detail-light.png
new file mode 100644
index 0000000..f86e8c7
Binary files /dev/null and b/images/dashboard/webhook-delivery-detail-light.png differ
diff --git a/images/dashboard/webhook-endpoint-detail-dark.png b/images/dashboard/webhook-endpoint-detail-dark.png
new file mode 100644
index 0000000..5fd605b
Binary files /dev/null and b/images/dashboard/webhook-endpoint-detail-dark.png differ
diff --git a/images/dashboard/webhook-endpoint-detail-light.png b/images/dashboard/webhook-endpoint-detail-light.png
new file mode 100644
index 0000000..e9259a5
Binary files /dev/null and b/images/dashboard/webhook-endpoint-detail-light.png differ
diff --git a/images/dashboard/webhooks-dark.png b/images/dashboard/webhooks-dark.png
new file mode 100644
index 0000000..56e72d9
Binary files /dev/null and b/images/dashboard/webhooks-dark.png differ
diff --git a/images/dashboard/webhooks-endpoint-validation-dark.png b/images/dashboard/webhooks-endpoint-validation-dark.png
new file mode 100644
index 0000000..9cf3f69
Binary files /dev/null and b/images/dashboard/webhooks-endpoint-validation-dark.png differ
diff --git a/images/dashboard/webhooks-list-dark.png b/images/dashboard/webhooks-list-dark.png
new file mode 100644
index 0000000..75f773c
Binary files /dev/null and b/images/dashboard/webhooks-list-dark.png differ
diff --git a/images/dashboard/webhooks-list-light.png b/images/dashboard/webhooks-list-light.png
new file mode 100644
index 0000000..4fb0ccc
Binary files /dev/null and b/images/dashboard/webhooks-list-light.png differ
diff --git a/images/dashboard/webhooks-new-endpoint-dark.png b/images/dashboard/webhooks-new-endpoint-dark.png
new file mode 100644
index 0000000..9f7b425
Binary files /dev/null and b/images/dashboard/webhooks-new-endpoint-dark.png differ
diff --git a/images/dashboard/webhooks-new-endpoint-light.png b/images/dashboard/webhooks-new-endpoint-light.png
new file mode 100644
index 0000000..1360336
Binary files /dev/null and b/images/dashboard/webhooks-new-endpoint-light.png differ
diff --git a/images/links/composer-basic-dark.png b/images/links/composer-basic-dark.png
new file mode 100644
index 0000000..d75a636
Binary files /dev/null and b/images/links/composer-basic-dark.png differ
diff --git a/images/links/composer-basic-light.png b/images/links/composer-basic-light.png
new file mode 100644
index 0000000..2db4da4
Binary files /dev/null and b/images/links/composer-basic-light.png differ
diff --git a/images/links/composer-created-dark.png b/images/links/composer-created-dark.png
new file mode 100644
index 0000000..ca91d01
Binary files /dev/null and b/images/links/composer-created-dark.png differ
diff --git a/images/links/composer-created-light.png b/images/links/composer-created-light.png
new file mode 100644
index 0000000..e9705d4
Binary files /dev/null and b/images/links/composer-created-light.png differ
diff --git a/images/links/composer-destination-dark.png b/images/links/composer-destination-dark.png
new file mode 100644
index 0000000..bc43218
Binary files /dev/null and b/images/links/composer-destination-dark.png differ
diff --git a/images/links/composer-destination-light.png b/images/links/composer-destination-light.png
new file mode 100644
index 0000000..b38fe0f
Binary files /dev/null and b/images/links/composer-destination-light.png differ
diff --git a/images/links/composer-domain-picker-dark.png b/images/links/composer-domain-picker-dark.png
new file mode 100644
index 0000000..9d45b33
Binary files /dev/null and b/images/links/composer-domain-picker-dark.png differ
diff --git a/images/links/composer-domain-picker-light.png b/images/links/composer-domain-picker-light.png
new file mode 100644
index 0000000..ad3e3dd
Binary files /dev/null and b/images/links/composer-domain-picker-light.png differ
diff --git a/images/links/composer-expiration-dark.png b/images/links/composer-expiration-dark.png
new file mode 100644
index 0000000..4b84f73
Binary files /dev/null and b/images/links/composer-expiration-dark.png differ
diff --git a/images/links/composer-expiration-light.png b/images/links/composer-expiration-light.png
new file mode 100644
index 0000000..15f2491
Binary files /dev/null and b/images/links/composer-expiration-light.png differ
diff --git a/images/links/composer-open-dark.png b/images/links/composer-open-dark.png
new file mode 100644
index 0000000..e74b3ba
Binary files /dev/null and b/images/links/composer-open-dark.png differ
diff --git a/images/links/composer-open-light.png b/images/links/composer-open-light.png
new file mode 100644
index 0000000..7cfc2ed
Binary files /dev/null and b/images/links/composer-open-light.png differ
diff --git a/images/links/composer-security-dark.png b/images/links/composer-security-dark.png
new file mode 100644
index 0000000..6647de2
Binary files /dev/null and b/images/links/composer-security-dark.png differ
diff --git a/images/links/composer-security-light.png b/images/links/composer-security-light.png
new file mode 100644
index 0000000..8453503
Binary files /dev/null and b/images/links/composer-security-light.png differ
diff --git a/images/links/emoji-browser-dark.png b/images/links/emoji-browser-dark.png
new file mode 100644
index 0000000..e6e40e8
Binary files /dev/null and b/images/links/emoji-browser-dark.png differ
diff --git a/images/links/emoji-browser-light.png b/images/links/emoji-browser-light.png
new file mode 100644
index 0000000..adee7ca
Binary files /dev/null and b/images/links/emoji-browser-light.png differ
diff --git a/images/links/geo-rules-editor-dark.png b/images/links/geo-rules-editor-dark.png
new file mode 100644
index 0000000..7d35714
Binary files /dev/null and b/images/links/geo-rules-editor-dark.png differ
diff --git a/images/links/geo-rules-editor-light.png b/images/links/geo-rules-editor-light.png
new file mode 100644
index 0000000..ec27a62
Binary files /dev/null and b/images/links/geo-rules-editor-light.png differ
diff --git a/images/links/links-bulk-bar-dark.png b/images/links/links-bulk-bar-dark.png
new file mode 100644
index 0000000..1c7d783
Binary files /dev/null and b/images/links/links-bulk-bar-dark.png differ
diff --git a/images/links/links-bulk-bar-light.png b/images/links/links-bulk-bar-light.png
new file mode 100644
index 0000000..d81d053
Binary files /dev/null and b/images/links/links-bulk-bar-light.png differ
diff --git a/images/links/meta-tags-editor-dark.png b/images/links/meta-tags-editor-dark.png
new file mode 100644
index 0000000..43f732f
Binary files /dev/null and b/images/links/meta-tags-editor-dark.png differ
diff --git a/images/links/meta-tags-editor-light.png b/images/links/meta-tags-editor-light.png
new file mode 100644
index 0000000..3cc17d0
Binary files /dev/null and b/images/links/meta-tags-editor-light.png differ
diff --git a/images/links/password-prompt-dark.png b/images/links/password-prompt-dark.png
new file mode 100644
index 0000000..563274f
Binary files /dev/null and b/images/links/password-prompt-dark.png differ
diff --git a/images/trust/report-form-dark.png b/images/trust/report-form-dark.png
new file mode 100644
index 0000000..7ba60b8
Binary files /dev/null and b/images/trust/report-form-dark.png differ
diff --git a/images/trust/report-form-light.png b/images/trust/report-form-light.png
new file mode 100644
index 0000000..a3d392c
Binary files /dev/null and b/images/trust/report-form-light.png differ
diff --git a/images/webhooks/deliveries-log-dark.png b/images/webhooks/deliveries-log-dark.png
new file mode 100644
index 0000000..87e46b8
Binary files /dev/null and b/images/webhooks/deliveries-log-dark.png differ
diff --git a/images/webhooks/deliveries-log-light.png b/images/webhooks/deliveries-log-light.png
new file mode 100644
index 0000000..d6de662
Binary files /dev/null and b/images/webhooks/deliveries-log-light.png differ
diff --git a/images/webhooks/endpoints-list-dark.png b/images/webhooks/endpoints-list-dark.png
new file mode 100644
index 0000000..1fb3edb
Binary files /dev/null and b/images/webhooks/endpoints-list-dark.png differ
diff --git a/images/webhooks/endpoints-list-light.png b/images/webhooks/endpoints-list-light.png
new file mode 100644
index 0000000..daea4aa
Binary files /dev/null and b/images/webhooks/endpoints-list-light.png differ
diff --git a/images/webhooks/reveal-secret-dark.png b/images/webhooks/reveal-secret-dark.png
new file mode 100644
index 0000000..09b3f86
Binary files /dev/null and b/images/webhooks/reveal-secret-dark.png differ
diff --git a/images/webhooks/reveal-secret-light.png b/images/webhooks/reveal-secret-light.png
new file mode 100644
index 0000000..5aa657a
Binary files /dev/null and b/images/webhooks/reveal-secret-light.png differ
diff --git a/introduction.mdx b/introduction.mdx
index b89d57d..24cb7e0 100644
--- a/introduction.mdx
+++ b/introduction.mdx
@@ -69,14 +69,14 @@ Get started with the Spoo.me API v1 in minutes:
List, update, and delete your shortened URLs
Get comprehensive statistics with flexible filtering and grouping
diff --git a/links/bulk-operations.mdx b/links/bulk-operations.mdx
new file mode 100644
index 0000000..c1aa1c8
--- /dev/null
+++ b/links/bulk-operations.mdx
@@ -0,0 +1,134 @@
+---
+title: "Bulk Operations"
+description: "Delete, toggle, expire, or move up to 100 links in one request"
+icon: "layers"
+---
+
+Cleaning up after a campaign should not mean a hundred API calls. The bulk endpoints apply one operation to up to 100 links per request and report a verdict for every link, so partial success is visible instead of silent.
+
+In the dashboard, selecting multiple links surfaces an action bar backed by these same endpoints.
+
+
+
+
+
+
+## The endpoints
+
+All bulk endpoints require authentication with scope `urls:manage` or `admin:all`. Rate limits count requests, not links, so a 100-link batch costs the same budget as a 2-link batch.
+
+| Endpoint | Does | Rate limit |
+|---|---|---|
+| `POST /api/v1/urls/bulk/delete` | Permanently deletes the listed links | 30/min, 100/day |
+| `POST /api/v1/urls/bulk/status` | Sets every listed link `ACTIVE` or `INACTIVE` | 60/min, 200/day |
+| `POST /api/v1/urls/bulk/expiry` | Sets or clears one `expire_after` on every listed link | 60/min, 200/day |
+| `POST /api/v1/urls/bulk/domain` | Moves every listed link to one target domain | 60/min, 200/day |
+| `DELETE /api/v1/urls?domain=` | Deletes **all** your links on one custom domain | 5/min, 50/day |
+
+## Request shape
+
+The four `bulk/*` endpoints share one envelope: an `ids` array of 1 to 100 link ids (the `id` returned by shorten and list endpoints), plus the operation's parameter.
+
+
+
+```bash cURL
+curl -X POST https://spoo.me/api/v1/urls/bulk/status \
+ -H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "ids": ["665f0c2f9e7a4b1d2c3d4e5f", "665f0c2f9e7a4b1d2c3d4e60"],
+ "status": "INACTIVE"
+ }'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/urls/bulk/status",
+ headers={"Authorization": "Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"},
+ json={
+ "ids": ["665f0c2f9e7a4b1d2c3d4e5f", "665f0c2f9e7a4b1d2c3d4e60"],
+ "status": "INACTIVE",
+ },
+)
+print(response.json()["summary"])
+```
+
+
+
+Per-operation parameters:
+
+| Endpoint | Parameter | Notes |
+|---|---|---|
+| `bulk/delete` | none | Ids only. |
+| `bulk/status` | `status` | `"ACTIVE"` or `"INACTIVE"`. Same-status links are success no-ops. |
+| `bulk/expiry` | `expire_after` | ISO 8601 or epoch seconds, must be in the future; `null` clears expiry. One value for the whole batch. Expired links whose expiry is extended or cleared reactivate. |
+| `bulk/domain` | `domain` | A custom domain you own (must be active), or `null` to move back to `spoo.me`. One target for the whole batch. |
+
+Envelope validation is all-or-nothing: an empty list, more than 100 ids, one malformed id, an unusable target domain, or a past expiry rejects the whole request with a `4xx` before anything runs.
+
+## Response shape
+
+A batch that ran always answers `200`, even if every item failed. Per-item failures are answers, not errors:
+
+```json
+{
+ "summary": { "total": 2, "succeeded": 1, "failed": 1 },
+ "results": [
+ { "id": "665f0c2f9e7a4b1d2c3d4e5f", "alias": "launch", "ok": true, "error_code": null, "error": null },
+ { "id": "665f0c2f9e7a4b1d2c3d4e60", "alias": null, "ok": false, "error_code": "not_found", "error": "URL not found" }
+ ]
+}
+```
+
+One row per unique id, in request order (duplicates are deduplicated, first occurrence wins). Branch on `error_code`; the `error` text is for display only.
+
+| `error_code` | Meaning |
+|---|---|
+| `not_found` | No such link in your account. Someone else's id answers the same. |
+| `forbidden` | The link is admin-blocked and cannot be modified or deleted. |
+| `conflict` | Domain move only: the alias is already taken on the target domain. |
+| `validation_error` | Domain move only: a reserved alias cannot move onto `spoo.me`. |
+| `internal` | Unexpected per-item failure, logged server-side. |
+| `not_attempted` | Processing stopped before reaching this item. |
+
+
+ Retrying a batch after a timeout is safe. Every operation sets an absolute value or deletes, so re-running it cannot double-apply: already-moved links report success no-ops, and already-deleted ids report `not_found`, which delete clients should treat as success.
+
+
+## Delete everything on a domain
+
+Retiring a custom domain? Instead of collecting ids, delete by domain:
+
+
+
+```bash cURL
+curl -X DELETE "https://spoo.me/api/v1/urls?domain=ac.me" \
+ -H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"
+```
+
+```python Python
+import requests
+
+response = requests.delete(
+ "https://spoo.me/api/v1/urls",
+ headers={"Authorization": "Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"},
+ params={"domain": "ac.me"},
+)
+print(response.json()) # {"message": "deleted 42 URL(s) on ac.me", "count": 42, "domain": "ac.me"}
+```
+
+
+
+This deletes every link you own on that domain. You must own the domain, and the system domain is refused: there is deliberately no single call that wipes your entire `spoo.me` inventory.
+
+
+ Both delete operations are irreversible. Deleted aliases become available for anyone to claim. If you might want a link back later, deactivate it with `bulk/status` instead.
+
+
+## When to use which
+
+- **A handful of edits to one link**: use the single-item endpoints (`PATCH`/`DELETE /api/v1/urls/{url_id}`).
+- **The same change across many links**: use `bulk/*`. One request per intent keeps you clear of rate limits that per-item loops would burn through.
+- **Everything on a domain has to go**: use `DELETE /api/v1/urls?domain=`.
diff --git a/links/creating-links.mdx b/links/creating-links.mdx
new file mode 100644
index 0000000..974aa31
--- /dev/null
+++ b/links/creating-links.mdx
@@ -0,0 +1,182 @@
+---
+title: "Creating Links"
+description: "Create short links from the dashboard composer or with one API call"
+icon: "square-pen"
+---
+
+Every link starts the same way: a destination URL goes in, a short link comes out. The dashboard composer and the API expose the same options, so anything you can configure in the UI you can automate.
+
+## Create a link in the dashboard
+
+
+
+ From the dashboard, open the link composer. It opens on the **Basic** tab with the destination field focused.
+
+
+
+
+
+
+
+ Enter the full URL you want to shorten, for example `https://acme.com/spring-launch-2026`. The composer normalizes the URL and shows you exactly what will be saved.
+
+
+
+
+
+
+
+ The short link control combines a domain picker and an alias field. The picker lists `spoo.me` plus any active [custom domains](/custom-domains); the alias field checks availability live as you type. Leave it empty for an auto-generated code, or use the dice button to get a suggestion. See [Custom Aliases](/links/custom-aliases).
+
+
+
+
+
+
+
+ Still on the Basic tab: **Expiration** stops the link redirecting at a specific moment (presets for common windows are one click away), and **Max clicks** deactivates it after a click budget is spent. See [Link Expiration](/links/link-expiration).
+
+
+
+
+
+
+
+ The **Security** tab holds the password field (see [Password Protection](/links/password-protection)), a **Block bots** switch that keeps crawlers and preview bots from consuming the redirect, and a **Private stats** switch that turns off the link's public stats page.
+
+
+ Press **Create link** (or hit Enter from the destination field). The short link is live immediately.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Depending on your account, the composer also shows a **Targeting** tab for [geo targeting](/links/geo-targeting) and a **Metadata** tab for [custom link previews](/links/custom-link-previews).
+
+## Create a link with the API
+
+The minimal call is one field:
+
+
+
+```bash cURL
+curl -X POST https://spoo.me/api/v1/shorten \
+ -H "Content-Type: application/json" \
+ -d '{"url": "https://acme.com/spring-launch-2026"}'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/shorten",
+ json={"url": "https://acme.com/spring-launch-2026"},
+)
+print(response.json()["short_url"])
+```
+
+
+
+The request body accepts `url` or `long_url` for the destination. With an API key (sent as `Authorization: Bearer `, scope `shorten:create` or `admin:all`) you get higher rate limits and the full option set:
+
+
+
+```bash cURL
+curl -X POST https://spoo.me/api/v1/shorten \
+ -H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "url": "https://acme.com/spring-launch-2026",
+ "alias": "launch",
+ "domain": "ac.me",
+ "password": "orbit.mango.42",
+ "max_clicks": 1000,
+ "expire_after": "2026-09-30T23:59:59Z",
+ "block_bots": true,
+ "private_stats": false
+ }'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/shorten",
+ headers={"Authorization": "Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"},
+ json={
+ "url": "https://acme.com/spring-launch-2026",
+ "alias": "launch",
+ "domain": "ac.me",
+ "password": "orbit.mango.42",
+ "max_clicks": 1000,
+ "expire_after": "2026-09-30T23:59:59Z",
+ "block_bots": True,
+ "private_stats": False,
+ },
+)
+print(response.json())
+```
+
+
+
+A successful call returns `201` with the new link, including the `id` that all management endpoints address the link by:
+
+```json
+{
+ "id": "665f0c2f9e7a4b1d2c3d4e5f",
+ "alias": "launch",
+ "short_url": "https://ac.me/launch",
+ "long_url": "https://acme.com/spring-launch-2026",
+ "owner_id": "507f1f77bcf86cd799439011",
+ "created_at": 1758153599,
+ "status": "ACTIVE",
+ "private_stats": false
+}
+```
+
+### Request fields
+
+| Field | Type | Notes |
+|---|---|---|
+| `url` / `long_url` | string | Required. `http://` or `https://`, up to 8,192 characters. |
+| `alias` | string | Custom short code, alphanumeric or emoji. Auto-generated if omitted. |
+| `alias_type` | string | `"alphanumeric"` (default) or `"emoji"`: the style to auto-generate when `alias` is omitted. |
+| `password` | string | 8 to 128 characters. |
+| `max_clicks` | integer | Positive click budget; the link expires when it is spent. |
+| `expire_after` | string or integer | ISO 8601 timestamp or Unix epoch seconds. Must be in the future. |
+| `block_bots` | boolean | Serve crawlers a preview page instead of the redirect. |
+| `private_stats` | boolean | Disable the public stats page. Defaults to `true` for authenticated links. |
+| `domain` | string | An owned, active custom domain. Requires authentication. |
+| `geo_rules` | object | Per-country destination overrides. See [Geo Targeting](/links/geo-targeting). |
+| `meta_tags` | object | Custom social preview. See [Custom Link Previews](/links/custom-link-previews). |
+
+## Anonymous vs authenticated
+
+Authentication is optional for creating links, but anonymous links are fire-and-forget: they are not tied to any account, so there is no way to edit, delete, or list them later.
+
+| | Anonymous | Authenticated |
+|---|---|---|
+| Create links | Yes | Yes |
+| Manage links later (edit, delete, list) | No | Yes |
+| Private stats | No | Yes (default on) |
+| Custom domains | No | Yes |
+| Geo targeting | No | Yes |
+| Custom meta tags | No | Yes |
+| Rate limit | 20/min, 1,000/day | 60/min, 5,000/day |
+
+
+ If you authenticate, your account's email must be verified. A signed-in user or API key belonging to an unverified account gets a `403` on link creation.
+
diff --git a/links/custom-aliases.mdx b/links/custom-aliases.mdx
new file mode 100644
index 0000000..291a804
--- /dev/null
+++ b/links/custom-aliases.mdx
@@ -0,0 +1,115 @@
+---
+title: "Custom Aliases"
+description: "Pick the short code yourself, check availability, and understand reserved names"
+icon: "signature"
+---
+
+The alias is the part after the slash: in `spoo.me/launch`, the alias is `launch`. Leave it out and one is generated for you; set it and the link reads like it was made for the campaign.
+
+## Alias rules
+
+| Style | Length | Allowed characters |
+|---|---|---|
+| Alphanumeric | 3 to 16 characters | `a-z`, `A-Z`, `0-9`, `_`, `-` |
+| Emoji | 1 to 15 emoji | See [Emoji Links](/links/emoji-links) |
+
+An alias is either fully alphanumeric or fully emoji. Mixed input is rejected.
+
+## Check availability
+
+### In the composer
+
+The alias field checks availability live as you type, scoped to whichever domain you picked. A spinner means the check is in flight, a check mark means the alias is yours to take, and an alert icon comes with a message telling you exactly what is wrong (too short, invalid characters, reserved, already taken).
+
+
+
+
+
+
+### With the API
+
+`GET /api/v1/shorten/check-alias` answers the same question the create endpoint would, without creating anything:
+
+
+
+```bash cURL
+curl "https://spoo.me/api/v1/shorten/check-alias?alias=launch"
+```
+
+```python Python
+import requests
+
+response = requests.get(
+ "https://spoo.me/api/v1/shorten/check-alias",
+ params={"alias": "launch"},
+)
+print(response.json())
+```
+
+
+
+```json
+{ "available": false, "reason": "taken" }
+```
+
+When `available` is `false`, `reason` tells you which rule failed first:
+
+| Reason | Meaning |
+|---|---|
+| `length` | Outside the 3 to 16 character bounds (or over 15 emoji). |
+| `format` | Characters outside the allowed set, or mixed emoji and text. |
+| `reserved` | The name is reserved on `spoo.me`. |
+| `taken` | Another link already uses this alias on that domain. |
+| `emoji_policy` | Emoji-only, but contains emoji outside the accepted set. |
+
+Out-of-range input still returns a structured answer instead of a validation error, so UIs can surface the reason inline. Pass `domain=ac.me` to check against a custom domain's namespace; that requires authentication and ownership of the domain. Rate limits: 180/min and 10,000/day authenticated, 60/min and 2,000/day anonymous.
+
+## Reserved names
+
+On `spoo.me`, aliases that match product paths (`api`, `dashboard`, `login`, `stats`, `docs`, `blog`, and similar) are rejected: a link at one of those addresses would be shadowed by the app itself and never resolve. The check is case-insensitive, so `Dashboard` is rejected along with `dashboard`.
+
+Two things follow from that:
+
+- **Custom domains are exempt.** Your domain carries no app paths, so `ac.me/dashboard` is a perfectly good short link.
+- **Moving a link enforces the rule too.** A link named `dashboard` on `ac.me` cannot be moved onto `spoo.me`; the move is rejected until the alias changes.
+
+## Per-domain namespaces
+
+Aliases are unique per domain, not globally. `spoo.me/launch` and `ac.me/launch` are two independent links that can point at different destinations. When you move a link between domains, the alias must be free on the target domain or the move fails with a conflict.
+
+## Suggest an alias
+
+Out of ideas? The dice button next to the alias field offers two generators:
+
+- **Suggest words** builds a memorable word-and-number alias like `ember-42`.
+- **Suggest emoji** picks three emoji from the server's safe generation pool.
+
+## Renaming a link
+
+The alias is editable after creation. Update it with `PATCH /api/v1/urls/{url_id}`; the new alias goes through the same rules and answers `409 Conflict` if it is already taken.
+
+
+
+```bash cURL
+curl -X PATCH https://spoo.me/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f \
+ -H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz" \
+ -H "Content-Type: application/json" \
+ -d '{"alias": "spring-launch"}'
+```
+
+```python Python
+import requests
+
+response = requests.patch(
+ "https://spoo.me/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f",
+ headers={"Authorization": "Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"},
+ json={"alias": "spring-launch"},
+)
+print(response.json())
+```
+
+
+
+
+ Renaming frees the old alias for anyone to claim. Links you have already shared under the old alias stop resolving.
+
diff --git a/links/custom-link-previews.mdx b/links/custom-link-previews.mdx
new file mode 100644
index 0000000..bbeb04b
--- /dev/null
+++ b/links/custom-link-previews.mdx
@@ -0,0 +1,148 @@
+---
+title: "Custom Link Previews"
+description: "Control the social card shown when your link is shared"
+icon: "image"
+---
+
+When a short link is pasted into WhatsApp, Discord, Slack, iMessage, or a social feed, the platform's crawler fetches the link and renders a preview card. By default that card comes from the destination page. Custom meta tags let you replace it: your own title, description, image, and accent color, served to every preview crawler that fetches the link.
+
+Use it to brand the card, A/B copy without touching the destination page, or give a preview to destinations that have none (raw files, deep app links, tracking-parameter-heavy URLs).
+
+
+ Custom link previews are being rolled out gradually, so they may not be enabled on your account yet. They require a verified, authenticated account; anonymous links cannot carry custom meta tags. Removing existing tags always works.
+
+
+## The fields
+
+| Field | Required | Limits |
+|---|---|---|
+| `title` | Yes | 1 to 120 characters. Rendered as the card headline (`og:title`). |
+| `description` | No | Up to 240 characters; most platforms render roughly 200. |
+| `image` | No | An `https://` URL (up to 2,048 characters) or an image upload as a data URI. |
+| `color` | No | Hex color like `#FF5733`. Shown as the embed accent on Discord (`theme-color`). |
+
+### Image: URL or upload
+
+`image` accepts two forms:
+
+- **An https URL** pointing at an image you already host. It is validated after the write; if it turns out not to be a usable image, the card simply renders without one.
+- **A data URI upload** (`data:image/png;base64,...`, also `jpeg` and `webp`). The image is validated, stored on spoo.me's CDN, and the link's tags point at the hosted copy, so the card keeps working even if your original goes away. Uploads are capped at 500KB decoded. SVG is rejected; no preview crawler renders it.
+
+
+ Use a 1200x630 image and keep it under 300KB. WhatsApp silently drops larger images, and images smaller than 200x200 are dropped by Facebook and WhatsApp entirely.
+
+
+## Set a preview in the composer
+
+The composer's **Metadata** tab starts by mirroring the destination page's own tags, labeled "fetched from destination", so you see the card the link would get anyway. The moment you edit any field, the tags become custom and are saved with the link; **Reset to destination** flips back to mirroring. A live preview renders the card beside the fields as you type.
+
+
+
+
+
+
+## Set a preview with the API
+
+Pass `meta_tags` on create or update. On `PATCH`, the object replaces the whole setting, and `null` removes it (the link falls back to the destination's own tags):
+
+
+
+```bash cURL
+curl -X POST https://spoo.me/api/v1/shorten \
+ -H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "url": "https://acme.com/spring-launch-2026",
+ "alias": "launch",
+ "meta_tags": {
+ "title": "Acme Spring Launch",
+ "description": "Everything we shipped this spring, in one page.",
+ "image": "https://acme.com/og/spring-launch.png",
+ "color": "#5B21B6"
+ }
+ }'
+```
+
+```python Python
+import requests
+
+headers = {"Authorization": "Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"}
+
+# Create with a custom preview
+requests.post(
+ "https://spoo.me/api/v1/shorten",
+ headers=headers,
+ json={
+ "url": "https://acme.com/spring-launch-2026",
+ "alias": "launch",
+ "meta_tags": {
+ "title": "Acme Spring Launch",
+ "description": "Everything we shipped this spring, in one page.",
+ "image": "https://acme.com/og/spring-launch.png",
+ "color": "#5B21B6",
+ },
+ },
+)
+
+# Later: replace the card
+requests.patch(
+ "https://spoo.me/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f",
+ headers=headers,
+ json={"meta_tags": {"title": "Spring Launch: now with pricing"}},
+)
+
+# Or remove it entirely
+requests.patch(
+ "https://spoo.me/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f",
+ headers=headers,
+ json={"meta_tags": None},
+)
+```
+
+
+
+To upload an image instead of linking one, send it as a data URI:
+
+```json
+{
+ "meta_tags": {
+ "title": "Acme Spring Launch",
+ "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
+ }
+}
+```
+
+## How crawlers see it
+
+Preview crawlers fetching your short link receive a page carrying your custom tags; human visitors are redirected as usual and never see it. The tags are served at the edge, so crawler fetches are fast worldwide.
+
+
+ Platforms cache preview cards aggressively, typically 7 to 30 days. Editing `meta_tags` on a link that has already been shared propagates slowly. The Facebook Sharing Debugger, LinkedIn Post Inspector, and Telegram's @WebpageBot can force a refresh on their platforms.
+
+
+## Prefill from the destination
+
+`GET /api/v1/metadata` fetches a destination page and returns its existing tags, so you can start from what the page already declares instead of a blank form (this is what the composer's mirroring uses):
+
+
+
+```bash cURL
+curl "https://spoo.me/api/v1/metadata?url=https://acme.com/spring-launch-2026" \
+ -H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"
+```
+
+```python Python
+import requests
+
+response = requests.get(
+ "https://spoo.me/api/v1/metadata",
+ headers={"Authorization": "Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"},
+ params={"url": "https://acme.com/spring-launch-2026"},
+)
+tags = response.json()
+print(tags["title"], tags["image"])
+```
+
+
+
+It returns normalized best-pick fields (`title`, `description`, `image`, `color`, `site_name`, chosen from OpenGraph, then Twitter, then plain HTML fallbacks) plus the raw `og` and `twitter` tag families. The URL must be `https`. Authentication is required with scope `urls:read`, `urls:manage`, or `admin:all`; the rate limit is 20/min and 500/day, with results cached server-side for about an hour, so repeat calls for the same URL are cheap. A page that cannot be fetched or is not HTML answers `422`; a page that does not respond in time answers `504`.
diff --git a/links/emoji-links.mdx b/links/emoji-links.mdx
new file mode 100644
index 0000000..766b9e8
--- /dev/null
+++ b/links/emoji-links.mdx
@@ -0,0 +1,106 @@
+---
+title: "Emoji Links"
+description: "Short links whose alias is made entirely of emoji"
+icon: "smile"
+---
+
+An emoji link uses emoji as the short code: `spoo.me/🚀🔥` is a real, working link. Emoji aliases work everywhere regular aliases do: the composer, the shorten API, availability checks, and link management.
+
+## Create an emoji link
+
+### In the composer
+
+The alias field accepts emoji directly. The emoji button opens a browser with the full accepted set, organized by category and searchable by name (`rocket` finds 🚀), with a counter showing how many of the 15 emoji you have left. The dice button's **Suggest emoji** option fills in a random three-emoji code.
+
+
+
+
+
+
+### With the API
+
+Pass emoji in the `alias` field, or set `alias_type` to `"emoji"` to have a three-emoji code generated for you:
+
+
+
+```bash cURL
+# Explicit emoji alias
+curl -X POST https://spoo.me/api/v1/shorten \
+ -H "Content-Type: application/json" \
+ -d '{"url": "https://acme.com/spring-launch-2026", "alias": "🚀🔥"}'
+
+# Auto-generated emoji alias
+curl -X POST https://spoo.me/api/v1/shorten \
+ -H "Content-Type: application/json" \
+ -d '{"url": "https://acme.com/spring-launch-2026", "alias_type": "emoji"}'
+```
+
+```python Python
+import requests
+
+# Explicit emoji alias
+requests.post(
+ "https://spoo.me/api/v1/shorten",
+ json={"url": "https://acme.com/spring-launch-2026", "alias": "🚀🔥"},
+)
+
+# Auto-generated emoji alias
+response = requests.post(
+ "https://spoo.me/api/v1/shorten",
+ json={"url": "https://acme.com/spring-launch-2026", "alias_type": "emoji"},
+)
+print(response.json()["short_url"]) # e.g. https://spoo.me/🌵🎈🍩
+```
+
+
+
+`short_url` in the response contains the emoji unencoded; browsers and HTTP clients percent-encode it on use, and both forms resolve.
+
+## The accepted set
+
+Not every emoji makes a reliable URL. Some emoji are actually multi-character sequences that fall apart on certain platforms: flag emoji render as bare letters on Chromium on Windows, and multi-person combinations (ZWJ sequences) can split into their parts. Others depend on an invisible variation selector to render as emoji at all.
+
+So emoji aliases draw from a curated pool. An emoji is accepted when it is:
+
+- a fully qualified, single-codepoint emoji (optionally with a skin-tone modifier), and
+- at or below **Emoji 15.1**, which renders on iOS 17.4+, Android 14+, and Windows 11 23H2+.
+
+ZWJ sequences, flags, keycaps, and text-style symbols are rejected. Auto-generated codes are held to a stricter cap of **Emoji 12.0** so they render even on Windows 10, and are always 3 emoji long. Aliases can be 1 to 15 emoji.
+
+| Policy | Value |
+|---|---|
+| Maximum length | 15 emoji |
+| Accepted for custom aliases | Fully qualified single-codepoint emoji, up to Emoji 15.1 |
+| Used for auto-generation | Same set capped at Emoji 12.0 |
+| Auto-generated length | 3 emoji |
+
+## Fetch the accepted set
+
+`GET /api/v1/emoji-set` returns the full catalogue, so pickers and validators never have to hard-code the policy. No authentication required.
+
+
+
+```bash cURL
+curl https://spoo.me/api/v1/emoji-set
+```
+
+```python Python
+import requests
+
+emoji_set = requests.get("https://spoo.me/api/v1/emoji-set").json()
+print(emoji_set["max_graphemes"]) # 15
+print(emoji_set["accept_max_version"]) # 15.1
+print(emoji_set["emoji"][0]) # first accepted entry
+```
+
+
+
+Each entry in `emoji` carries the character (`c`), a searchable name (`n`, e.g. `"rocket"`), its category (`g`, e.g. `"Smileys & Emotion"`), whether it is in the auto-generation pool (`gen`), and optional extra search keywords (`k`). Skin-tone variants are not enumerated; the tone modifier is applied client-side to a base emoji.
+
+
+ The response is cacheable for a day and carries an `ETag`. Send it back as `If-None-Match` and you get a `304` while the set is unchanged; a deploy that expands the set changes the ETag automatically. Rate limit: 60/min, 2,000/day.
+
+
+## Variation selectors
+
+Some emoji, like ⭐, can be typed with or without an invisible variation selector (`U+FE0F`), and the two byte sequences look identical. Emoji aliases are canonicalized on write and on lookup: variation selectors are stripped, so `⭐️🎉` and `⭐🎉` are the same alias and resolve to the same link. Aliases are stored and returned in canonical form, and availability checks run against it, so you never end up with two visually identical links.
diff --git a/links/geo-targeting.mdx b/links/geo-targeting.mdx
new file mode 100644
index 0000000..8eb53f0
--- /dev/null
+++ b/links/geo-targeting.mdx
@@ -0,0 +1,140 @@
+---
+title: "Geo Targeting"
+description: "Send visitors from specific countries to different destinations"
+icon: "earth"
+---
+
+One short link, different destinations by country. Share `ac.me/launch` everywhere: visitors from Germany land on `acme.de/launch`, visitors from India land on `acme.in/launch`, and everyone else gets the default destination.
+
+Geo rules are matched at the edge, at redirect time, based on the visitor's country. There is no extra hop and no client-side detection.
+
+
+ Geo targeting is being rolled out gradually, so it may not be enabled on your account yet. It requires authentication; anonymous links cannot carry geo rules. Removing existing rules always works.
+
+
+## How rules work
+
+A link carries at most one rule set: a map of ISO 3166-1 alpha-2 country codes to destination URLs.
+
+```json
+{
+ "DE": "https://acme.de/launch",
+ "IN": "https://acme.in/launch"
+}
+```
+
+- A visitor from a listed country is redirected to that country's URL.
+- A visitor from any other country gets the link's default destination (`long_url`).
+- The default destination is the fallback; it is always required and always active.
+
+| Constraint | Value |
+|---|---|
+| Countries per link | Up to 50 |
+| Country codes | ISO 3166-1 alpha-2 (`DE`, `IN`, `US`, ...), validated against the real ISO list |
+| Destination URLs | Same validation as the main destination, up to 8,192 characters |
+
+Codes are case-insensitive on input (`de` becomes `DE`), but a request containing both `de` and `DE` is rejected rather than silently merged.
+
+## Set geo rules
+
+### In the composer
+
+Open the **Targeting** tab. Each rule is a country picker paired with a destination URL; add as many rows as you need.
+
+
+
+
+
+
+### With the API
+
+Pass `geo_rules` on create:
+
+
+
+```bash cURL
+curl -X POST https://spoo.me/api/v1/shorten \
+ -H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "url": "https://acme.com/launch",
+ "alias": "launch",
+ "domain": "ac.me",
+ "geo_rules": {
+ "DE": "https://acme.de/launch",
+ "IN": "https://acme.in/launch"
+ }
+ }'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/shorten",
+ headers={"Authorization": "Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"},
+ json={
+ "url": "https://acme.com/launch",
+ "alias": "launch",
+ "domain": "ac.me",
+ "geo_rules": {
+ "DE": "https://acme.de/launch",
+ "IN": "https://acme.in/launch",
+ },
+ },
+)
+print(response.json())
+```
+
+
+
+Validation failures name the exact rule: an unknown code or a blocked destination answers `422` with a field path like `geo_rules.DE`.
+
+## Edit or remove rules
+
+`geo_rules` on `PATCH /api/v1/urls/{url_id}` replaces the whole map. Send the complete set you want, not a diff. `null` or `{}` removes all rules; omitting the field leaves existing rules unchanged.
+
+
+
+```bash cURL
+# Replace the rule set (drops IN, adds FR)
+curl -X PATCH https://spoo.me/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f \
+ -H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz" \
+ -H "Content-Type: application/json" \
+ -d '{"geo_rules": {"DE": "https://acme.de/launch", "FR": "https://acme.fr/launch"}}'
+
+# Remove all rules
+curl -X PATCH https://spoo.me/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f \
+ -H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz" \
+ -H "Content-Type: application/json" \
+ -d '{"geo_rules": null}'
+```
+
+```python Python
+import requests
+
+headers = {"Authorization": "Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"}
+
+# Replace the rule set (drops IN, adds FR)
+requests.patch(
+ "https://spoo.me/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f",
+ headers=headers,
+ json={"geo_rules": {
+ "DE": "https://acme.de/launch",
+ "FR": "https://acme.fr/launch",
+ }},
+)
+
+# Remove all rules
+requests.patch(
+ "https://spoo.me/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f",
+ headers=headers,
+ json={"geo_rules": None},
+)
+```
+
+
+
+
+ Keep country destinations parallel to the default one. If `ac.me/launch` points at a campaign page, point `DE` at the same campaign on your German site rather than a generic homepage; a visitor should never be able to tell they were rerouted.
+
diff --git a/links/link-expiration.mdx b/links/link-expiration.mdx
new file mode 100644
index 0000000..299ce1c
--- /dev/null
+++ b/links/link-expiration.mdx
@@ -0,0 +1,95 @@
+---
+title: "Link Expiration"
+description: "Stop a link at a point in time or after a click budget"
+icon: "timer"
+---
+
+Links can expire two ways, and you can combine both on one link:
+
+- **Time expiry** (`expire_after`): the link stops redirecting at a specific moment.
+- **Max clicks** (`max_clicks`): the link stops redirecting once it has been clicked that many times.
+
+Both live on the composer's **Basic** tab, next to the alias: pick a date and time (presets for common windows are one click away) or type a click budget.
+
+## Set expiration
+
+
+
+```bash cURL
+curl -X POST https://spoo.me/api/v1/shorten \
+ -H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "url": "https://acme.com/flash-sale",
+ "alias": "sale",
+ "expire_after": "2026-08-01T00:00:00Z",
+ "max_clicks": 500
+ }'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/shorten",
+ headers={"Authorization": "Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"},
+ json={
+ "url": "https://acme.com/flash-sale",
+ "alias": "sale",
+ "expire_after": "2026-08-01T00:00:00Z",
+ "max_clicks": 500,
+ },
+)
+print(response.json())
+```
+
+
+
+`expire_after` accepts an ISO 8601 timestamp (`2026-08-01T00:00:00Z`) or Unix epoch seconds (`1785542400`), and must be in the future. `max_clicks` is any positive integer.
+
+## How an expired link behaves
+
+Expiry is exact, not eventually consistent. The link's `status` is derived on every read: the moment `expire_after` passes, or the moment total clicks reach `max_clicks`, the link reads `EXPIRED` and stops redirecting. Visitors get an error page instead of the destination.
+
+The link itself is not deleted. It stays in your dashboard and API listings with status `EXPIRED`, keeps its analytics, and can be brought back.
+
+## Update, clear, or extend
+
+Change expiration on a live link with `PATCH /api/v1/urls/{url_id}`:
+
+| Intent | Request body |
+|---|---|
+| Extend the deadline | `{"expire_after": "2026-09-01T00:00:00Z"}` |
+| Remove time expiry | `{"expire_after": null}` |
+| Raise the click budget | `{"max_clicks": 2000}` |
+| Remove the click limit | `{"max_clicks": null}` (or `0`) |
+
+
+
+```bash cURL
+curl -X PATCH https://spoo.me/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f \
+ -H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz" \
+ -H "Content-Type: application/json" \
+ -d '{"expire_after": "2026-09-01T00:00:00Z", "max_clicks": null}'
+```
+
+```python Python
+import requests
+
+response = requests.patch(
+ "https://spoo.me/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f",
+ headers={"Authorization": "Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"},
+ json={"expire_after": "2026-09-01T00:00:00Z", "max_clicks": None},
+)
+print(response.json())
+```
+
+
+
+### Reactivation is automatic
+
+When a link is `EXPIRED` and your update lifts the condition that expired it, the link reactivates in the same request: extending or clearing `expire_after`, raising `max_clicks` above the current click count, or clearing it, all flip the link back to `ACTIVE`. No separate status call needed. (If your update explicitly sets a `status`, that value wins instead.)
+
+## Bulk expiry
+
+To set or clear expiration on many links at once, use `POST /api/v1/urls/bulk/expiry`: one `expire_after` value applied to up to 100 links per request, with per-link results. Expired links in the batch reactivate under the same rules. See [Bulk Operations](/links/bulk-operations).
diff --git a/links/managing-links.mdx b/links/managing-links.mdx
new file mode 100644
index 0000000..a8f4e69
--- /dev/null
+++ b/links/managing-links.mdx
@@ -0,0 +1,118 @@
+---
+title: "Managing Links"
+description: "Search, filter, edit, and bulk-manage every link you own from the Links page"
+icon: "table-properties"
+---
+
+The Links page (`/dashboard/links`) is the working view of every link on your account: a live table with search, filters, inline editing, and bulk actions. Everything here is backed by the same API that powers [bulk operations](/links/bulk-operations) and the management endpoints.
+
+## The table
+
+Each row shows the link with its destination favicon, a one-click copy button, a status chip, and sortable **Clicks**, **Last click**, and **Created** columns.
+
+
+
+
+
+
+Protection settings show up as inline glyphs next to the short link: one each for [password protection](/links/password-protection), bot blocking, and [private stats](/analytics/public-stats). You can tell how a link is configured without opening it.
+
+The table refreshes on an interval (1 minute by default). Change the interval or refresh manually from the refresh control in the toolbar.
+
+
+
+
+
+
+## Search and filters
+
+The search box matches free text across your links. The filters popover narrows by status (**Active**, **Inactive**, **Expired**, **Blocked**) and by protection (**Password protected**, **Click-limited**).
+
+
+
+
+
+
+### Date ranges
+
+The date-range picker takes presets, a calendar, or a plain-language range typed directly into the input:
+
+| You type | You get |
+|---|---|
+| `3h` | The last 3 hours |
+| `last 7 days` | The last 7 days |
+| `now-3d to now-1d` | A window from 3 days ago to 1 day ago |
+
+
+
+
+
+
+
+ The `now-3d to now-1d` form is the one to reach for when you want a window that excludes today, for example to compare a campaign day without partial-day noise. The same picker appears on the Analytics board.
+
+
+## Row actions
+
+The actions menu on each row covers the quick moves: **Copy link**, **Open**, **Full page**, **Pin to dashboard**, **Deactivate**, and **Delete**.
+
+
+
+
+
+
+- **Deactivate** stops the link redirecting without deleting it; reactivate any time.
+- **Delete** is permanent.
+- **Pin to dashboard** puts this link's clicks-over-time chart on your Analytics board. See [pinned widgets](/analytics/custom-dashboards#pin-a-link-to-the-board).
+- **Full page** opens the link's [stats and settings page](/analytics/link-stats).
+
+## Edit in place
+
+Click a row to open the edit sheet: the full edit form in a side panel, without leaving the list. It covers the destination, the domain and alias (this is also where you move a single link to another domain), password, expiry, max clicks, block bots, and private stats, plus a shortcut to the full page.
+
+
+
+
+
+
+## Bulk actions
+
+
+
+ Use the row checkboxes, or select the whole page with the header checkbox or `⌘A`.
+
+
+
+
+
+
+
+ A bar appears with **Deactivate** and **Delete** up front, and **Move to domain**, **Set expiry**, and **Export CSV** under the overflow menu.
+
+
+
+
+
+
+
+ Destructive and cross-domain actions confirm before running. **Move to domain** asks for the target domain; **Set expiry** takes one expiration for the whole selection; **Export CSV** downloads the selected links.
+
+
+
+
+
+
+
+
+The bar is backed by the [bulk endpoints](/links/bulk-operations), so the same operations are scriptable, with a per-link verdict on partial success.
+
+## Pin a link's chart to Analytics
+
+**Pin to dashboard** (in the row actions menu) adds a clicks-over-time widget for that link to your Analytics board and confirms with a toast. The widget carries a scope chip with the link's alias, so it always reads as that one link's traffic no matter what the rest of the board is showing.
+
+
+
+
+
+
+Pinned widgets are regular board widgets: move, resize, or remove them in [edit mode](/analytics/custom-dashboards).
diff --git a/links/password-protection.mdx b/links/password-protection.mdx
new file mode 100644
index 0000000..88c542a
--- /dev/null
+++ b/links/password-protection.mdx
@@ -0,0 +1,136 @@
+---
+title: "Password Protection"
+description: "Lock a link's redirect behind a password"
+icon: "lock"
+---
+
+A password-protected link does not redirect until the visitor enters the right password. Use it for pre-release pages, private documents, or anything you want to share in public channels without making it public.
+
+## Set a password
+
+### In the composer
+
+Open the **Security** tab and fill in the password field. The **Suggest** button generates a strong, readable password (three word-and-number segments) and reveals it so you can copy it before creating the link.
+
+
+
+
+
+
+### With the API
+
+
+
+```bash cURL
+curl -X POST https://spoo.me/api/v1/shorten \
+ -H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "url": "https://acme.com/board-deck-q3",
+ "alias": "board-deck",
+ "password": "orbit.mango.42"
+ }'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/shorten",
+ headers={"Authorization": "Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"},
+ json={
+ "url": "https://acme.com/board-deck-q3",
+ "alias": "board-deck",
+ "password": "orbit.mango.42",
+ },
+)
+print(response.json()["short_url"])
+```
+
+
+
+### Password rules
+
+Link passwords must be **8 to 128 characters**. They are stored hashed, so they cannot be read back, only replaced or removed.
+
+## What visitors see
+
+Opening `https://spoo.me/board-deck` shows a password page instead of redirecting. The destination is not revealed until the correct password is submitted; a wrong password re-renders the prompt.
+
+
+
+
+
+## Change or remove the password
+
+Update the link with `PATCH /api/v1/urls/{url_id}`. A new value replaces the password; `null` removes protection entirely.
+
+
+
+```bash cURL
+# Rotate the password
+curl -X PATCH https://spoo.me/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f \
+ -H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz" \
+ -H "Content-Type: application/json" \
+ -d '{"password": "delta.copper.77"}'
+
+# Remove protection
+curl -X PATCH https://spoo.me/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f \
+ -H "Authorization: Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz" \
+ -H "Content-Type: application/json" \
+ -d '{"password": null}'
+```
+
+```python Python
+import requests
+
+headers = {"Authorization": "Bearer spoo_AbCdEfGhIjKlMnOpQrStUvWxYz"}
+
+# Rotate the password
+requests.patch(
+ "https://spoo.me/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f",
+ headers=headers,
+ json={"password": "delta.copper.77"},
+)
+
+# Remove protection
+requests.patch(
+ "https://spoo.me/api/v1/urls/665f0c2f9e7a4b1d2c3d4e5f",
+ headers=headers,
+ json={"password": None},
+)
+```
+
+
+
+## Passwords and public stats
+
+The password also gates the link's public stats page. If the link has public stats enabled, `spoo.me/stats/board-deck` asks for the same password before showing any numbers.
+
+On the API, `GET /api/v1/public/stats/{alias}` answers `401` with code `password_required` for a protected link. The password travels only in a POST body, never in the URL, so it cannot leak into logs or referrer headers:
+
+
+
+```bash cURL
+curl -X POST https://spoo.me/api/v1/public/stats/board-deck \
+ -H "Content-Type: application/json" \
+ -d '{"password": "orbit.mango.42"}'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/public/stats/board-deck",
+ json={"password": "orbit.mango.42"},
+)
+print(response.json())
+```
+
+
+
+A wrong password answers `401` with code `invalid_password`. As the link's owner, your session or API key bypasses the gate entirely, in the dashboard and on the stats endpoints.
+
+
+ Links created while authenticated default to private stats, which disables the public stats page altogether. The password unlock only comes into play when you have switched **Private stats** off for the link.
+
diff --git a/openapi-v0.json b/openapi-v0.json
index 6c09076..9ad7538 100644
--- a/openapi-v0.json
+++ b/openapi-v0.json
@@ -49,13 +49,13 @@ paths:
alias:
type: string
maxLength: 16
- pattern: ^[a-zA-Z0-9]+$
- description: Custom alias for the shortened URL. Must be alphanumeric and maximum 16 characters. Anything beyond 16 characters will be **stripped by the API**.
+ pattern: ^[a-zA-Z0-9_-]+$
+ description: Custom alias for the shortened URL. May contain letters, numbers, hyphens and underscores, maximum 16 characters. Anything beyond 16 characters will be **stripped by the API**.
example: example
password:
type: string
minLength: 8
- description: Password to access the shortened URL. Must be at least 8 characters long, contain at least one letter, one number, and one special character ('@' or '.').
+ description: Password to access the shortened URL. Must be at least 8 characters long, contain at least one letter, one number, and one special character ('@' or '.'). Two special characters cannot appear consecutively.
example: SuperStrongPassword@18322
max-clicks:
type: integer
@@ -99,32 +99,37 @@ paths:
UrlError:
summary: Invalid or missing URL
value:
- error: UrlError
- message: Invalid or missing URL
+ UrlError: URL is required
AliasError:
summary: Invalid or taken alias
value:
- error: AliasError
- message: Alias is invalid or already taken
+ AliasError: Alias already exists
+ alias: example
PasswordError:
summary: Password requirements not met
value:
- error: PasswordError
- message: Password does not meet requirements
+ PasswordError: Password must be at least 8 characters long, contain a letter, a number and a special character ('@' or '.')
MaxClicksError:
summary: Invalid max-clicks value
value:
- error: MaxClicksError
- message: Max clicks must be a positive integer
+ MaxClicksError: max-clicks must be an positive integer
+ '403':
+ description: Forbidden - The URL is blocked
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ example:
+ BlockedUrlError: Blocked URL ⛔
'429':
description: Too Many Requests - Rate limit exceeded
content:
application/json:
schema:
- $ref: '#/components/schemas/ErrorResponse'
+ $ref: '#/components/schemas/RateLimitResponse'
example:
- error: RateLimitError
- message: Rate limit exceeded. Please try again later.
+ error: Too many requests
+ code: rate_limit_exceeded
/emoji:
post:
summary: Create Emoji URLs
@@ -160,12 +165,12 @@ paths:
emojies:
type: string
maxLength: 15
- description: Custom emoji sequence for the shortened URL. Must contain only emojis, no other character is allowed. Anything beyond 15 emojis will be **stripped by the API**.
+ description: Custom emoji sequence for the shortened URL. Must contain only emojis, no other character is allowed, maximum 15 emojis. Longer or invalid sequences are **rejected** with an `EmojiError`.
example: 🐍🐍
password:
type: string
minLength: 8
- description: Password to access the shortened URL. Must be at least 8 characters long, contain at least one letter, one number, and one special character ('@' or '.').
+ description: Password to access the shortened URL. Must be at least 8 characters long, contain at least one letter, one number, and one special character ('@' or '.'). Two special characters cannot appear consecutively.
example: Python.Snake63
max-clicks:
type: integer
@@ -209,32 +214,36 @@ paths:
UrlError:
summary: Invalid or missing URL
value:
- error: UrlError
- message: Invalid or missing URL
+ UrlError: URL is required
EmojiError:
summary: Invalid or taken emoji sequence
value:
- error: EmojiError
- message: Emoji sequence is invalid or already taken
+ EmojiError: Invalid emoji
PasswordError:
summary: Password requirements not met
value:
- error: PasswordError
- message: Password does not meet requirements
+ PasswordError: Password must be at least 8 characters long, contain a letter, a number and a special character ('@' or '.')
MaxClicksError:
summary: Invalid max-clicks value
value:
- error: MaxClicksError
- message: Max clicks must be a positive integer
+ MaxClicksError: max-clicks must be an positive integer
+ '403':
+ description: Forbidden - The URL is blocked
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ example:
+ BlockedUrlError: Blocked URL ⛔
'429':
description: Too Many Requests - Rate limit exceeded
content:
application/json:
schema:
- $ref: '#/components/schemas/ErrorResponse'
+ $ref: '#/components/schemas/RateLimitResponse'
example:
- error: RateLimitError
- message: Rate limit exceeded. Please try again later.
+ error: Too many requests
+ code: rate_limit_exceeded
/stats/{shortCode}:
post:
summary: Get URL Statistics
@@ -275,8 +284,7 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
- error: PasswordError
- message: Invalid password
+ PasswordError: Invalid Password
'404':
description: URL not found
content:
@@ -284,8 +292,7 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
- error: UrlError
- message: The requested URL never existed
+ UrlError: The requested Url never existed
/export/{shortCode}/{exportFormat}:
post:
summary: Export URL Data
@@ -350,13 +357,11 @@ paths:
PasswordError:
summary: Invalid password
value:
- error: PasswordError
- message: Invalid password
+ PasswordError: Invalid Password
FormatError:
summary: Invalid export format
value:
- error: FormatError
- message: Invalid export format
+ FormatError: Invalid format; format must be json, csv, xlsx or xml
'404':
description: URL not found
content:
@@ -364,22 +369,26 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
- error: UrlError
- message: The requested URL never existed
+ UrlError: The requested Url never existed
components:
schemas:
ErrorResponse:
+ type: object
+ description: Legacy error responses are keyed by the error type. The object contains a single property named after the error (for example `UrlError`, `AliasError`, `EmojiError`, `PasswordError`, `MaxClicksError`, `FormatError`, `BlockedUrlError`) whose value is the human-readable message. Alias errors additionally include an `alias` property echoing the rejected alias.
+ additionalProperties:
+ type: string
+ RateLimitResponse:
type: object
properties:
error:
- type: string
- description: Error type identifier
- message:
type: string
description: Human-readable error message
+ code:
+ type: string
+ description: Machine-readable error code
required:
- error
- - message
+ - code
UrlStatistics:
type: object
properties:
diff --git a/openapi-v1.json b/openapi-v1.json
index 1ecdefa..afff359 100644
--- a/openapi-v1.json
+++ b/openapi-v1.json
@@ -1,4858 +1,12985 @@
{
- "openapi": "3.1.0",
- "info": {
- "title": "spoo.me",
- "description": "REST API for spoo.me \u2014 free and open-source URL shortening service serving 400k+ redirects/day.\n\nAuthenticate using either:\n- **API Key**: `Authorization: Bearer spoo_`\n- **JWT Token**: `Authorization: Bearer ` (obtained via /auth/login)\n- **Session Cookie**: `access_token` cookie (set automatically on login)",
- "contact": {
- "name": "spoo.me",
- "url": "https://spoo.me/contact",
- "email": "support@spoo.me"
- },
- "license": {
- "name": "Apache 2.0",
- "url": "https://github.com/spoo-me/spoo/blob/main/LICENSE"
- },
- "version": "1.0.0"
+ "openapi": "3.1.0",
+ "info": {
+ "title": "spoo.me",
+ "description": "REST API for spoo.me - free and open-source URL shortening service serving 400k+ redirects/day.\n\nAuthenticate using either:\n- **API Key**: `Authorization: Bearer spoo_`\n- **JWT Token**: `Authorization: Bearer ` (obtained via /auth/login)\n- **Session Cookie**: `access_token` cookie (set automatically on login)",
+ "contact": {
+ "name": "spoo.me",
+ "url": "https://spoo.me/contact",
+ "email": "support@spoo.me"
},
- "paths": {
- "/health": {
- "get": {
- "tags": [
- "System"
+ "license": {
+ "name": "AGPL-3.0",
+ "url": "https://github.com/spoo-me/spoo/blob/main/LICENSE"
+ },
+ "version": "1.0.0"
+ },
+ "paths": {
+ "/health": {
+ "get": {
+ "tags": [
+ "System"
+ ],
+ "summary": "Health Check",
+ "description": "Check the health of the application and its dependencies.\n\nPings MongoDB and Redis to determine overall system status:\n\n- **healthy** (200): Both MongoDB and Redis are reachable.\n- **degraded** (200): MongoDB is reachable but Redis is down or not configured.\n- **unhealthy** (503): MongoDB is unreachable -- the app cannot function.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: None",
+ "operationId": "healthCheck",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ }
+ },
+ "security": []
+ }
+ },
+ "/auth/login": {
+ "post": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Login",
+ "description": "Authenticate with email and password.\n\nReturns JWT access token and sets secure HTTP-only cookies for both\naccess and refresh tokens. The refresh token can be used at\n``POST /auth/refresh`` to obtain new tokens without re-authenticating.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 5/min, 50/day\n\n**Security**: Returns identical error for wrong email and wrong password\nto prevent user enumeration.",
+ "operationId": "loginUser",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/LoginRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/LoginResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": []
+ }
+ },
+ "/auth/register": {
+ "post": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Register",
+ "description": "Create a new user account with email and password.\n\nImmediately signs the user in by returning a JWT access token and setting\nsecure HTTP-only cookies. A verification email is sent best-effort; the\n``verification_sent`` field indicates whether it succeeded.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 5/min, 50/day\n\n**Notes**: The account is created even if the verification email fails.\nThe user must verify their email before accessing protected resources.",
+ "operationId": "registerUser",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RegisterRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RegisterResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": []
+ }
+ },
+ "/auth/refresh": {
+ "post": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Refresh Tokens",
+ "description": "Rotate the access and refresh token pair.\n\nReads the ``refresh_token`` cookie, validates it, and issues a new\naccess/refresh pair. Both cookies are replaced. If the refresh token\nis missing, expired, or invalid, all auth cookies are cleared and a\n401 response is returned.\n\n**Authentication**: Requires a valid ``refresh_token`` cookie\n\n**Rate Limits**: 20/min\n\n**Notes**: The old refresh token is invalidated after use (rotation).",
+ "operationId": "refreshTokens",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RefreshResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "security": []
+ }
+ },
+ "/auth/logout": {
+ "post": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Logout",
+ "description": "Log the current user out by clearing auth cookies.\n\nRemoves the ``access_token`` and ``refresh_token`` HTTP-only cookies.\nAlways succeeds regardless of whether the user was authenticated.\n\n**Authentication**: Not required\n\n**Rate Limits**: 60/hour",
+ "operationId": "logout",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/LogoutResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/me": {
+ "get": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Get Current User",
+ "description": "Return the authenticated user's full profile.\n\nIncludes email, verification status, linked OAuth providers, plan,\nand profile picture. Useful for populating the UI after login or\non page load.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 60/min",
+ "operationId": "getCurrentUser",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MeResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Update Current User",
+ "description": "Update the authenticated user's profile.\n\nCurrently only ``user_name`` is editable. Registration makes the\ndisplay name write-once, so accounts that skipped it need this to\nset one later. Send a string to set the name or ``null`` to clear it.\n\n**Authentication**: Required (JWT only - API keys cannot rename the account)\n\n**Rate Limits**: 10/min",
+ "operationId": "updateCurrentUser",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateProfileRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MeResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/set-password": {
+ "post": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Set Password",
+ "description": "Set a password for an OAuth-only account.\n\nAllows users who signed up via OAuth to add a password so they can\nalso log in with email + password. Fails if the user already has a\npassword set.\n\n**Authentication**: Required (JWT only - API keys cannot set passwords)\n\n**Rate Limits**: 5/min",
+ "operationId": "setPassword",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SetPasswordRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MessageResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/send-verification": {
+ "post": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Send Verification Email",
+ "description": "Send a 6-digit OTP verification code to the user's email.\n\nThe code expires after the duration returned in ``expires_in`` (seconds).\nReturns 400 if the user's email is already verified.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 1/minute, 3/hour\n\n**Notes**: Previous unused OTPs are invalidated when a new one is sent.",
+ "operationId": "sendVerification",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SendVerificationResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/verify-email": {
+ "post": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Verify Email",
+ "description": "Verify the user's email address using a 6-digit OTP code.\n\nOn success, new JWT tokens are issued with ``email_verified=true`` in the\nclaims, and auth cookies are updated. A welcome email is sent best-effort.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 10/hour\n\n**Notes**: The OTP must match the most recently sent code and must not\nbe expired. Expired or already-used codes are rejected.",
+ "operationId": "verifyEmail",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/VerifyEmailRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/VerifyEmailResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/request-password-reset": {
+ "post": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Request Password Reset",
+ "description": "Request a password-reset OTP to be sent via email.\n\nAlways returns the same success response regardless of whether the\nemail is registered. This prevents user enumeration attacks.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 3/hour\n\n**Security**: Timing-safe -- response time is constant whether or not\nthe account exists.",
+ "operationId": "requestPasswordReset",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestPasswordResetRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MessageResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": []
+ }
+ },
+ "/auth/reset-password": {
+ "post": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Reset Password",
+ "description": "Reset the account password using a 6-digit OTP code.\n\nThe OTP must have been requested via ``POST /auth/request-password-reset``.\nOn success the password is updated immediately and the OTP is consumed.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 5/hour\n\n**Notes**: Expired or already-used OTPs are rejected with a 400 error.",
+ "operationId": "resetPassword",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ResetPasswordRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MessageResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": []
+ }
+ },
+ "/auth/device/token": {
+ "post": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Exchange Device Auth Code",
+ "description": "Exchange a one-time device auth code for JWT tokens.\n\nThe code is obtained from the callback page after the user authenticates\non spoo.me. The PKCE ``code_verifier`` must match the ``code_challenge``\nthe app sent when initiating the flow. Returns access and refresh tokens\nscoped to the app's grant.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 10/min",
+ "operationId": "exchangeDeviceCode",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeviceTokenRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeviceTokenResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": []
+ }
+ },
+ "/auth/device/refresh": {
+ "post": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Refresh Device Auth Tokens",
+ "description": "Refresh an app's JWT tokens using a refresh token.\n\nAccepts the refresh token in the request body (not cookies) for use\nby external apps (browser extensions, desktop, CLI, bots). If the\nrefresh token contains an ``app_id`` claim, the server verifies the\napp grant is still active - revoked apps cannot refresh - and re-reads\nthe grant's scopes so scope changes propagate on the next refresh.\n\n**Authentication**: Not required (the refresh token itself is the credential)\n\n**Rate Limits**: 20/min",
+ "operationId": "refreshDeviceTokens",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeviceRefreshRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeviceRefreshResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": []
+ }
+ },
+ "/auth/onboarding": {
+ "get": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Get Onboarding State",
+ "description": "Return the caller's onboarding resume pointer.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 60/min\n\n**Notes**: The pointer self-expires 24h after the last write. Empty\n(`step: null`) means nothing to resume - never started, expired, or\ncompleted. Whether the account has *finished* onboarding is\n`user.onboarded_at` on `/auth/me`, not part of this cache.",
+ "operationId": "getOnboardingState",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OnboardingStateResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Update Onboarding State",
+ "description": "Persist the caller's resume pointer (refreshes the 24h TTL).\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 30/min\n\n**Notes**: Best-effort - if Redis is unavailable the response echoes\nthe submitted state without persisting it; clients keep their local\ncopy as the fallback source of truth.",
+ "operationId": "putOnboardingState",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OnboardingStateRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OnboardingStateResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/auth/onboarding/complete": {
+ "post": {
+ "tags": [
+ "Authentication"
+ ],
+ "summary": "Complete Onboarding",
+ "description": "Mark onboarding finished for this account.\n\nStamps `onboarded_at` on the user document (first completion wins -\nrepeat calls are idempotent and keep the original timestamp), records\nthe optional HDYHAU answer, and drops the resume pointer.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 30/min",
+ "operationId": "completeOnboarding",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OnboardingCompleteRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OnboardingCompleteResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/oauth/providers": {
+ "get": {
+ "tags": [
+ "OAuth"
+ ],
+ "summary": "List OAuth Providers",
+ "description": "List all OAuth providers linked to the authenticated user's account.\n\nReturns each linked provider's name, email, and link date, plus whether\nthe user has a password set (needed by the UI to decide if unlinking\nthe last provider is allowed).\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 60/min",
+ "operationId": "listOAuthProviders",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OAuthProvidersResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/oauth/providers/{provider_name}/unlink": {
+ "delete": {
+ "tags": [
+ "OAuth"
+ ],
+ "summary": "Unlink OAuth Provider",
+ "description": "Remove an OAuth provider link from the authenticated user's account.\n\nFails if the provider is the user's only authentication method (i.e.,\nno password set and no other providers linked).\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 5/min",
+ "operationId": "unlinkOAuthProvider",
+ "parameters": [
+ {
+ "name": "provider_name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Provider Name"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MessageResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/oauth/{provider}": {
+ "get": {
+ "tags": [
+ "OAuth"
+ ],
+ "summary": "OAuth Login",
+ "description": "Initiate the OAuth authorization flow for the given provider.\n\nRedirects the user to the provider's consent screen (e.g., Google,\nGitHub). After the user grants access, the provider redirects back\nto ``GET /oauth/{provider}/callback``.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 10/min\n\n**Supported providers**: google, github (configurable)",
+ "operationId": "initiateOAuthLogin",
+ "parameters": [
+ {
+ "name": "provider",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Provider"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": []
+ }
+ },
+ "/oauth/{provider}/callback": {
+ "get": {
+ "tags": [
+ "OAuth"
+ ],
+ "summary": "OAuth Callback",
+ "description": "Handle the OAuth provider callback after user authorization.\n\nValidates the CSRF state parameter, exchanges the authorization code for\nan access token, fetches the user's profile from the provider, and then\neither logs in an existing user or creates a new account. On success,\nredirects to ``/dashboard`` with JWT cookies set.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 20/min\n\n**Notes**: This endpoint is called by the OAuth provider, not directly\nby the client. The ``state`` query parameter is required for CSRF protection.",
+ "operationId": "oauthCallback",
+ "parameters": [
+ {
+ "name": "provider",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Provider"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": []
+ }
+ },
+ "/oauth/{provider}/link": {
+ "get": {
+ "tags": [
+ "OAuth"
+ ],
+ "summary": "Link OAuth Provider",
+ "description": "Initiate an OAuth flow to link a provider to the authenticated account.\n\nSimilar to ``GET /oauth/{provider}`` but includes the user's ID in the\nstate token so the callback knows to link rather than log in. The user\nmust already be authenticated.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 5/min",
+ "operationId": "linkOAuthProvider",
+ "parameters": [
+ {
+ "name": "provider",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Provider"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/shorten": {
+ "post": {
+ "tags": [
+ "URL Shortening"
+ ],
+ "summary": "Create Shortened URL",
+ "description": "Create a new shortened URL.\n\nCreate a shortened URL with optional customization including password\nprotection, expiration, click limits, and bot blocking. Authenticated\nusers may target an owned, ACTIVE custom domain via the ``domain`` field.\n\n**Emoji aliases**: ``alias`` also accepts an emoji-only short code\n(e.g. ``🚀🔥``) - 1-15 fully-qualified emoji; ZWJ sequences, flags,\nkeycaps, and text-style symbols are rejected (they render or copy\ninconsistently across platforms). Set ``alias_type: \"emoji\"`` to\nauto-generate an emoji code instead of an alphanumeric one. Stored\nand returned in canonical form (variation selectors stripped).\n\n**Authentication**: Optional - higher rate limits when authenticated.\nRequired if ``domain`` is supplied.\n\n**API Key Scope**: `shorten:create` or `admin:all`\n\n**Rate Limits**:\n\n- Authenticated: 60/min, 5,000/day\n- Anonymous: 20/min, 1,000/day\n\n**Anonymous Usage Consequences**:\n\n- Lower rate limits\n- Cannot manage or view URLs later\n- Cannot use private stats\n- URLs not linked to any account\n- Cannot use custom domains\n- Cannot use geo targeting\n- Cannot use custom meta tags",
+ "operationId": "shortenUrl",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateUrlRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UrlResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {},
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "JWTAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/shorten/check-alias": {
+ "get": {
+ "tags": [
+ "URL Shortening"
+ ],
+ "summary": "Check Alias Availability",
+ "description": "Check whether a proposed alias would be accepted by POST /api/v1/shorten.\n\nReason codes on a negative result (``length``/``format``/``reserved``/\n``taken``/``emoji_policy``) let the UI render precise inline feedback\nwithout duplicating the validation rules. Emoji aliases are checked in\ncanonical form (variation selectors stripped).\n\nPass ``domain`` to scope the check to a custom-domain tenant - required\nfor the create modal's live availability indicator when the user has\npicked a non-default domain. Authz mirrors the shorten endpoint: anon\ncallers can't probe custom domains; authed callers must own the target.\n\n**Authentication**: Optional - higher rate limits when authenticated.\nRequired if ``domain`` is supplied.",
+ "operationId": "checkAliasAvailability",
+ "parameters": [
+ {
+ "name": "alias",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64,
+ "description": "Candidate alias to check (alphanumeric or emoji-only).",
+ "examples": [
+ "mylink",
+ "🚀🔥"
+ ],
+ "title": "Alias"
+ },
+ "description": "Candidate alias to check (alphanumeric or emoji-only)."
+ },
+ {
+ "name": "domain",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 253
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Scope the availability check to a custom domain fqdn. Requires authentication + ownership of an ACTIVE custom domain. Omit for the system default namespace.",
+ "examples": [
+ "links.acme.com"
+ ],
+ "title": "Domain"
+ },
+ "description": "Scope the availability check to a custom domain fqdn. Requires authentication + ownership of an ACTIVE custom domain. Omit for the system default namespace."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AliasCheckResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {},
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "JWTAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/emoji-set": {
+ "get": {
+ "tags": [
+ "URL Shortening"
+ ],
+ "summary": "Accepted Emoji Set",
+ "description": "Return the accepted emoji catalogue and its policy caps.\n\n``emoji`` lists every single-codepoint emoji a custom alias may use\n(the picker's list), ordered by canonical Unicode group and within-group\norder so a picker opens on Smileys rather than symbols. Each entry\ncarries ``c`` (the raw canonical character), ``n`` (a searchable name like\n\"rocket\"), ``g`` (its canonical Unicode category, e.g. \"Smileys &\nEmotion\", for category tabs), ``gen`` (whether it is in the server's\nauto-generation pool), and an optional ``k`` (extra search aliases when\nthe source lists any). Skin tone is a client-side modifier appended to a\nbase emoji, so skin-tone variants are not enumerated.\n\n**Authentication**: None. The response is identical for everyone.\n\n**Caching**: Fresh for a day, then served stale for a week while\nrevalidating. A content-derived ``ETag`` is returned; send it back as\n``If-None-Match`` to get a ``304`` when the set is unchanged.\n\n**Rate Limits**: 60/min, 2,000/day.",
+ "operationId": "getEmojiSet",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmojiSetResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "security": []
+ }
+ },
+ "/api/v1/urls": {
+ "get": {
+ "tags": [
+ "Link Management"
+ ],
+ "summary": "List Your URLs",
+ "description": "List all URLs owned by the authenticated user.\n\nReturns a paginated list of shortened URLs with support for filtering,\nsorting, and full-text search on aliases and destination URLs.\n\n**Authentication**: Required.\n\n**API Key Scope**: `urls:manage`, `urls:read`, or `admin:all`\n\n**Rate Limits**: 60/min, 5,000/day\n\n**Pagination**: Use `page` and `pageSize` query params. Response includes\n`hasNext` boolean and `total` count.\n\n**Sorting**: Sort by `created_at`, `last_click`, or `total_clicks` in\nascending or descending order.\n\n**Filtering**: Pass a JSON-encoded `filter` parameter with fields like\n`status`, `createdAfter`, `createdBefore`, `passwordSet`, `maxClicksSet`,\nand `search`.",
+ "operationId": "listUrls",
+ "parameters": [
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number (default: 1)",
+ "examples": [
+ 1
+ ],
+ "default": 1,
+ "title": "Page"
+ },
+ "description": "Page number (default: 1)"
+ },
+ {
+ "name": "pageSize",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 100,
+ "minimum": 1,
+ "description": "Items per page (default: 20, max: 100)",
+ "examples": [
+ 20
+ ],
+ "default": 20,
+ "title": "Pagesize"
+ },
+ "description": "Items per page (default: 20, max: 100)"
+ },
+ {
+ "name": "sortBy",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "enum": [
+ "created_at",
+ "last_click",
+ "total_clicks"
+ ],
+ "type": "string",
+ "description": "Field to sort by",
+ "default": "created_at",
+ "title": "Sortby"
+ },
+ "description": "Field to sort by"
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "enum": [
+ "ascending",
+ "asc",
+ "1",
+ "descending",
+ "desc",
+ "-1"
+ ],
+ "type": "string",
+ "description": "Sort direction",
+ "default": "descending",
+ "title": "Sortorder"
+ },
+ "description": "Sort direction"
+ },
+ {
+ "name": "filter",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 10000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "JSON string containing filter criteria for URLs. Format: `{\"field\": value}`\n\n**Available filter fields:**\n\n- **status** - Filter by URL status (`\"ACTIVE\"` or `\"INACTIVE\"`)\n- **createdAfter** - Filter URLs created after this date (ISO 8601 datetime or Unix timestamp)\n- **createdBefore** - Filter URLs created before this date (ISO 8601 datetime or Unix timestamp)\n- **passwordSet** - Filter by password protection (boolean: `true`/`false`)\n- **maxClicksSet** - Filter by click limit presence (boolean: `true`/`false`)\n- **search** - Search in alias or long_url (case-insensitive string)\n\n**Value formats:**\n\n- **status**: String - `\"ACTIVE\"` or `\"INACTIVE\"` (case-sensitive)\n- **createdAfter / createdBefore**: ISO 8601 datetime string (e.g., `\"2024-01-01T00:00:00Z\"`) or Unix timestamp (e.g., `1704067200`)\n- **passwordSet / maxClicksSet**: Boolean - `true` or `false`\n- **search**: String - case-insensitive search term\n\n**Examples:**\n\n- `{\"status\": \"ACTIVE\"}` - Only active URLs\n- `{\"passwordSet\": true}` - Only password-protected URLs\n- `{\"createdAfter\": \"2024-01-01T00:00:00Z\"}` - URLs created after Jan 1, 2024\n- `{\"status\": \"ACTIVE\", \"maxClicksSet\": true}` - Active URLs with click limits\n- `{\"search\": \"example\"}` - URLs containing \"example\" in alias or long_url\n- `{\"createdAfter\": \"2024-01-01\", \"createdBefore\": \"2024-12-31\", \"status\": \"ACTIVE\"}` - Active URLs from 2024",
+ "examples": [
+ "{\"status\":\"ACTIVE\"}",
+ "{\"passwordSet\": true}",
+ "{\"createdAfter\": \"2024-01-01T00:00:00Z\"}",
+ "{\"status\": \"ACTIVE\", \"maxClicksSet\": true}",
+ "{\"search\": \"example\"}",
+ "{\"createdAfter\": \"2024-01-01\", \"createdBefore\": \"2024-12-31\", \"status\": \"ACTIVE\"}"
+ ],
+ "title": "Filter"
+ },
+ "description": "JSON string containing filter criteria for URLs. Format: `{\"field\": value}`\n\n**Available filter fields:**\n\n- **status** - Filter by URL status (`\"ACTIVE\"` or `\"INACTIVE\"`)\n- **createdAfter** - Filter URLs created after this date (ISO 8601 datetime or Unix timestamp)\n- **createdBefore** - Filter URLs created before this date (ISO 8601 datetime or Unix timestamp)\n- **passwordSet** - Filter by password protection (boolean: `true`/`false`)\n- **maxClicksSet** - Filter by click limit presence (boolean: `true`/`false`)\n- **search** - Search in alias or long_url (case-insensitive string)\n\n**Value formats:**\n\n- **status**: String - `\"ACTIVE\"` or `\"INACTIVE\"` (case-sensitive)\n- **createdAfter / createdBefore**: ISO 8601 datetime string (e.g., `\"2024-01-01T00:00:00Z\"`) or Unix timestamp (e.g., `1704067200`)\n- **passwordSet / maxClicksSet**: Boolean - `true` or `false`\n- **search**: String - case-insensitive search term\n\n**Examples:**\n\n- `{\"status\": \"ACTIVE\"}` - Only active URLs\n- `{\"passwordSet\": true}` - Only password-protected URLs\n- `{\"createdAfter\": \"2024-01-01T00:00:00Z\"}` - URLs created after Jan 1, 2024\n- `{\"status\": \"ACTIVE\", \"maxClicksSet\": true}` - Active URLs with click limits\n- `{\"search\": \"example\"}` - URLs containing \"example\" in alias or long_url\n- `{\"createdAfter\": \"2024-01-01\", \"createdBefore\": \"2024-12-31\", \"status\": \"ACTIVE\"}` - Active URLs from 2024"
+ },
+ {
+ "name": "filterBy",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 10000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Alias for filter parameter.",
+ "title": "Filterby"
+ },
+ "description": "Alias for filter parameter."
+ },
+ {
+ "name": "domain",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 253
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter URLs by exact custom domain fqdn.",
+ "examples": [
+ "links.acme.com"
+ ],
+ "title": "Domain"
+ },
+ "description": "Filter URLs by exact custom domain fqdn."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UrlListResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Link Management"
+ ],
+ "summary": "Bulk Delete URLs on a Custom Domain",
+ "description": "Bulk-delete all URLs the caller owns on the given custom domain.\n\n**Authentication**: Required.\n\n**API Key Scope**: `urls:manage` or `admin:all`\n\n**Rate Limits**: 5/min, 50/day.\n\n**Restrictions**: refuses the system-default domain - protects against\naccidental nuke of the user's entire spoo.me URL inventory. Caller must\nown the domain.",
+ "operationId": "bulkDeleteUrls",
+ "parameters": [
+ {
+ "name": "domain",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 253,
+ "description": "Custom domain fqdn whose URLs should be deleted.",
+ "examples": [
+ "links.acme.com"
+ ],
+ "title": "Domain"
+ },
+ "description": "Custom domain fqdn whose URLs should be deleted."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BulkDeleteUrlsResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/urls/{url_id}": {
+ "get": {
+ "tags": [
+ "Link Management"
+ ],
+ "summary": "Get URL by ID",
+ "description": "Fetch a single URL you own by its id.\n\nReturns the same item shape as one element of `GET /urls` - including\nderived `status`, so expired/disabled/blocked links return normally\nwith their status field (you are reading your inventory, not following\na redirect).\n\n**Authentication**: Required - you must own the URL.\n\n**API Key Scope**: `urls:manage`, `urls:read`, or `admin:all`\n\n**Rate Limits**: 60/min, 5,000/day\n\n**Errors**:\n\n- `400` - malformed id (not a valid ObjectId)\n- `404` - no URL with that id in your account. A URL owned by someone\n else answers identically; this endpoint never confirms foreign ids.",
+ "operationId": "getUrl",
+ "parameters": [
+ {
+ "name": "url_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "Unique identifier of the URL (MongoDB ObjectId).",
+ "title": "Url Id"
+ },
+ "description": "Unique identifier of the URL (MongoDB ObjectId)."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UrlListItem"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "Link Management"
+ ],
+ "summary": "Update URL",
+ "description": "Update an existing URL's properties.\n\nPartially update a shortened URL. Only provided fields are modified; omitted\nfields remain unchanged. Pass `null` to remove optional settings like\n`password`, `max_clicks`, or `expire_after`.\n\n**Authentication**: Required - you must own the URL.\n\n**API Key Scope**: `urls:manage` or `admin:all`\n\n**Rate Limits**: 120/min, 2,000/day\n\n**Updatable Fields**: `long_url`, `alias`, `password`, `block_bots`,\n`max_clicks`, `expire_after`, `private_stats`, `status`, `domain`,\n`geo_rules`, `meta_tags`\n\n**Notes**:\n\n- Setting `max_clicks` to `0` or `null` removes the click limit\n- Changing the `alias` checks availability and may fail with 409 Conflict\n- Setting `domain` moves the URL to a different tenant; caller must own\n the target as an ACTIVE custom domain, or pass `null` to move back to\n the system default. Alias collision is verified on the target.\n- `geo_rules` replaces the whole map; pass `null` or `{}` to remove all\n rules\n- The `url_id` is the MongoDB ObjectId, not the alias",
+ "operationId": "updateUrl",
+ "parameters": [
+ {
+ "name": "url_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 24,
+ "maxLength": 24,
+ "pattern": "^[0-9a-f]{24}$",
+ "description": "Unique identifier of the URL",
+ "title": "Url Id"
+ },
+ "description": "Unique identifier of the URL"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateUrlRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateUrlResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Link Management"
+ ],
+ "summary": "Delete URL",
+ "description": "Delete a URL permanently.\n\n**This action is IRREVERSIBLE.** The URL, its alias, and all associated\nclick analytics data will be permanently deleted. The alias may be reclaimed\nby another user afterward.\n\n**Authentication**: Required - you must own the URL.\n\n**API Key Scope**: `urls:manage` or `admin:all`\n\n**Rate Limits**: 60/min, 1,000/day\n\n**Recommendation**: Consider setting the URL status to `INACTIVE` via\n`PATCH /urls/{url_id}/status` instead if you may want to restore it later.",
+ "operationId": "deleteUrl",
+ "parameters": [
+ {
+ "name": "url_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 24,
+ "maxLength": 24,
+ "pattern": "^[0-9a-f]{24}$",
+ "description": "Unique identifier of the URL",
+ "title": "Url Id"
+ },
+ "description": "Unique identifier of the URL"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteUrlResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/urls/{domain}/{alias}": {
+ "get": {
+ "tags": [
+ "Link Management"
+ ],
+ "summary": "Get URL by Domain and Alias",
+ "description": "Fetch a single URL you own by its natural key: domain + alias.\n\nThe address every short link is already known by - no id lookup\nround-trip needed. Returns the same item shape as one element of\n`GET /urls`, status-blind for the owner (expired/disabled/blocked\nlinks return with their status field).\n\n**Authentication**: Required - you must own the URL.\n\n**API Key Scope**: `urls:manage`, `urls:read`, or `admin:all`\n\n**Rate Limits**: 60/min, 5,000/day\n\n**Errors**:\n\n- `404` - no URL at that address in your account. Unknown domains and\n links owned by someone else answer identically; this endpoint never\n confirms what exists outside your account.\n\n**Note**: only current-generation links resolve here - that is all the\nmanaged collection holds.",
+ "operationId": "getUrlByAddress",
+ "parameters": [
+ {
+ "name": "domain",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "maxLength": 253,
+ "description": "Domain the short link lives on. Always explicit: pass the system domain (e.g. `spoo.me`) for default-namespace links or an owned custom domain fqdn. Case and `:port` are ignored.",
+ "examples": [
+ "spoo.me",
+ "links.acme.com"
+ ],
+ "title": "Domain"
+ },
+ "description": "Domain the short link lives on. Always explicit: pass the system domain (e.g. `spoo.me`) for default-namespace links or an owned custom domain fqdn. Case and `:port` are ignored."
+ },
+ {
+ "name": "alias",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "Short code of the URL. Emoji aliases arrive percent-encoded.",
+ "examples": [
+ "mylink"
+ ],
+ "title": "Alias"
+ },
+ "description": "Short code of the URL. Emoji aliases arrive percent-encoded."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UrlListItem"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/urls/bulk/delete": {
+ "post": {
+ "tags": [
+ "Link Management"
+ ],
+ "summary": "Bulk Delete URLs",
+ "description": "Permanently delete up to 100 URLs you own, addressed by id.\n\n**This action is irreversible.** Deleted aliases become available\nfor anyone to claim. Historical analytics are not removed.\n\nNot to be confused with `DELETE /api/v1/urls?domain=` (bulk delete\nby custom domain): that operation answers \"everything on this\ndomain\", this one answers \"exactly these links\".\n\n**Per-item verdicts** (`error_code`): `not_found` - no such URL in\nyour account (an id you don't own answers the same); `forbidden` -\nthe URL is admin-blocked and cannot be deleted.\n\n**Retry semantics**: re-sending the batch after a timeout is safe;\nalready-deleted ids report `not_found`, which a client should treat\nas success-equivalent for delete.\n\n**Rate Limits**: 30/min, 100/day - counted per request, not per id.",
+ "operationId": "bulkDeleteUrlsByIds",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BulkDeleteUrlsRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BulkUrlOperationResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/urls/bulk/status": {
+ "post": {
+ "tags": [
+ "Link Management"
+ ],
+ "summary": "Bulk Update URL Status",
+ "description": "Activate or deactivate up to 100 URLs you own in one request.\n\nSemantics match the single-item status endpoint exactly: `ACTIVE`\nenables redirects, `INACTIVE` disables them, `EXPIRED` URLs set to\n`ACTIVE` reactivate, and setting the status a link already has is a\nsuccess no-op.\n\n**Per-item verdicts** (`error_code`): `not_found` - no such URL in\nyour account; `forbidden` - the URL is admin-blocked and cannot be\nmodified.\n\n**Rate Limits**: 60/min, 200/day - counted per request, not per id.",
+ "operationId": "bulkUpdateUrlStatus",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BulkUpdateStatusRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BulkUrlOperationResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/urls/bulk/expiry": {
+ "post": {
+ "tags": [
+ "Link Management"
+ ],
+ "summary": "Bulk Update URL Expiry",
+ "description": "Set or clear the expiration on up to 100 URLs you own.\n\nOne value for the whole batch - ISO 8601 or epoch seconds, must be\nin the future (a past value rejects the whole request; it could\nnever be right for any item). `null` clears expiry. `EXPIRED` URLs\nwhose expiry is extended or cleared reactivate, exactly like the\nsingle-item update.\n\n**Per-item verdicts** (`error_code`): `not_found` - no such URL in\nyour account; `forbidden` - the URL is admin-blocked and cannot be\nmodified.\n\n**Rate Limits**: 60/min, 200/day - counted per request, not per id.",
+ "operationId": "bulkUpdateUrlExpiry",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BulkUpdateExpiryRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BulkUrlOperationResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/urls/bulk/domain": {
+ "post": {
+ "tags": [
+ "Link Management"
+ ],
+ "summary": "Bulk Move URL Domain",
+ "description": "Move up to 100 URLs you own to a different domain in one request.\n\nOne target for the whole batch: a custom domain you own (it must be\nverified and ACTIVE - an unusable target rejects the whole request,\nsince it could never be right for any item), or `null` to move back\nto the system default.\n\nSemantics match the single-item domain update exactly, per item:\nalready on the target is a success no-op, a reserved alias cannot\nmove onto the system default, and the alias must be free on the\ntarget namespace. Within one batch, items are processed in request\norder - two links with the same alias moving to one target means the\nfirst wins and the second reports `conflict`, deterministically.\n\n**Per-item verdicts** (`error_code`): `not_found` - no such URL in\nyour account; `forbidden` - the URL is admin-blocked; `conflict` -\nalias already taken on the target; `validation_error` - reserved\nalias on the system default.\n\n**Retry semantics**: re-sending the batch is safe - items that\nalready moved report success no-ops.\n\n**Rate Limits**: 60/min, 200/day - counted per request, not per id.",
+ "operationId": "bulkUpdateUrlDomain",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BulkMoveDomainRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BulkUrlOperationResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/urls/{url_id}/status": {
+ "patch": {
+ "tags": [
+ "Link Management"
+ ],
+ "summary": "Update URL Status",
+ "description": "Update only the status of a URL (ACTIVE / INACTIVE).\n\nToggle a URL between active and inactive without modifying other properties.\n\n**Authentication**: Required - you must own the URL.\n\n**API Key Scope**: `urls:manage` or `admin:all`\n\n**Rate Limits**: 120/min, 2,000/day\n\n**Status Values** (user-editable via this endpoint):\n\n- `ACTIVE` - URL is accessible and redirects normally\n- `INACTIVE` - URL is disabled and returns an error page\n\n**Note**: `BLOCKED` is an admin-set status - blocked URLs cannot be modified\nor deleted by the owner. `EXPIRED` URLs (auto-set on max clicks or expiry\ntime) can be reactivated by setting status back to `ACTIVE` - but only\nafter the expiry condition is lifted (raise/clear `max_clicks`, extend/\nclear `expire_after` via PATCH), otherwise the URL immediately reads\nas expired again.\n\n**Use Cases**:\n\n- Set `INACTIVE` to temporarily disable redirects without deleting the URL\n- Set `ACTIVE` to re-enable a previously disabled URL",
+ "operationId": "updateUrlStatus",
+ "parameters": [
+ {
+ "name": "url_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 24,
+ "maxLength": 24,
+ "pattern": "^[0-9a-f]{24}$",
+ "description": "Unique identifier of the URL",
+ "title": "Url Id"
+ },
+ "description": "Unique identifier of the URL"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateUrlStatusRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateUrlResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/stats": {
+ "get": {
+ "tags": [
+ "Statistics"
+ ],
+ "summary": "URL Statistics",
+ "description": "Get click statistics for URLs.\n\nRetrieve aggregated click analytics with flexible grouping, filtering,\nand time-range options.\n\n**Authentication**: Optional for `scope=anon` (public stats on a single URL);\nrequired for `scope=all` (all URLs owned by the user).\n\n**API Key Scope**: `stats:read`, `urls:read`, or `admin:all`\n\n**Rate Limits**:\n\n- Authenticated: 60/min, 5,000/day\n- Anonymous: 20/min, 1,000/day\n\n**Scopes**:\n\n- `scope=anon` + `short_code=` - public stats for one URL (if stats are not private)\n- `scope=all` - aggregate stats across all URLs owned by the authenticated user\n\n**Grouping Dimensions**: `time`, `browser`, `os`, `country`, `city`,\n`referrer`, `short_code`\n\n**Metrics**: `clicks`, `unique_clicks`\n\n**Filtering**: Filter by `browser`, `os`, `country`, `city`, `referrer`,\nor `short_code` using query params or a JSON `filters` object.",
+ "operationId": "getStats",
+ "parameters": [
+ {
+ "name": "scope",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "enum": [
+ "all",
+ "anon"
+ ],
+ "type": "string",
+ "description": "Statistics scope: `all` (authenticated only) or `anon` (public access).\n\n- `all` - aggregate stats across all URLs owned by the authenticated user. Requires authentication.\n- `anon` - public stats for a single URL. Requires `short_code` parameter. No authentication needed (unless stats are private).",
+ "default": "all",
+ "title": "Scope"
+ },
+ "description": "Statistics scope: `all` (authenticated only) or `anon` (public access).\n\n- `all` - aggregate stats across all URLs owned by the authenticated user. Requires authentication.\n- `anon` - public stats for a single URL. Requires `short_code` parameter. No authentication needed (unless stats are private)."
+ },
+ {
+ "name": "short_code",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 50
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "URL alias to query stats for. **Required** when `scope=anon`. When `scope=all`, this is optional and filters stats to a specific URL.",
+ "examples": [
+ "mylink"
+ ],
+ "title": "Short Code"
+ },
+ "description": "URL alias to query stats for. **Required** when `scope=anon`. When `scope=all`, this is optional and filters stats to a specific URL."
+ },
+ {
+ "name": "start_date",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 50
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to the URL creation date.",
+ "examples": [
+ "2025-01-01T00:00:00Z"
+ ],
+ "title": "Start Date"
+ },
+ "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to the URL creation date."
+ },
+ {
+ "name": "end_date",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 50
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now.",
+ "examples": [
+ "2025-12-31T23:59:59Z"
+ ],
+ "title": "End Date"
+ },
+ "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now."
+ },
+ {
+ "name": "group_by",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 200
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` - group by time buckets (day/week/month, auto-selected based on range)\n- `browser` - group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` - group by operating system (e.g., Windows, macOS, Linux)\n- `device` - group by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` - group by country\n- `city` - group by city\n- `referrer` - group by referrer URL\n- `short_code` - group by URL alias (only with `scope=all`)\n- `utm_source` - group by the `utm_source` tag on the short link (untagged clicks appear as `(none)`)\n- `utm_medium` - group by the `utm_medium` tag\n- `utm_campaign` - group by the `utm_campaign` tag\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser.",
+ "examples": [
+ "time,browser",
+ "country",
+ "time,country,browser"
+ ],
+ "title": "Group By"
+ },
+ "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` - group by time buckets (day/week/month, auto-selected based on range)\n- `browser` - group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` - group by operating system (e.g., Windows, macOS, Linux)\n- `device` - group by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` - group by country\n- `city` - group by city\n- `referrer` - group by referrer URL\n- `short_code` - group by URL alias (only with `scope=all`)\n- `utm_source` - group by the `utm_source` tag on the short link (untagged clicks appear as `(none)`)\n- `utm_medium` - group by the `utm_medium` tag\n- `utm_campaign` - group by the `utm_campaign` tag\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser."
+ },
+ {
+ "name": "metrics",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 200
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` - total click count\n- `unique_clicks` - unique visitor count (deduplicated by IP + User-Agent)",
+ "examples": [
+ "clicks,unique_clicks",
+ "clicks"
+ ],
+ "title": "Metrics"
+ },
+ "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` - total click count\n- `unique_clicks` - unique visitor count (deduplicated by IP + User-Agent)"
+ },
+ {
+ "name": "timezone",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "maxLength": 50,
+ "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`.",
+ "examples": [
+ "UTC",
+ "America/New_York"
+ ],
+ "default": "UTC",
+ "title": "Timezone"
+ },
+ "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`."
+ },
+ {
+ "name": "filters",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 5000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` - Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` - Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `device` - Filter by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` - Filter by country name (e.g., United States, Canada, Germany)\n- `city` - Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` - Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `short_code` - Filter by URL alias (e.g., mylink, promo2024) - **not allowed** with `scope=anon`\n- `utm_source` / `utm_medium` / `utm_campaign` - Filter by campaign tags; `(none)` matches untagged clicks\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` - Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` - US/CA clicks from Chrome\n- `{\"short_code\": [\"link1\", \"link2\"]}` - Stats for specific URLs (`scope=all` only)\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below).",
+ "examples": [
+ "{\"browser\":[\"Chrome\",\"Firefox\"]}",
+ "{\"country\":[\"United States\",\"Canada\"],\"browser\":[\"Chrome\"]}"
+ ],
+ "title": "Filters"
+ },
+ "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` - Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` - Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `device` - Filter by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` - Filter by country name (e.g., United States, Canada, Germany)\n- `city` - Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` - Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `short_code` - Filter by URL alias (e.g., mylink, promo2024) - **not allowed** with `scope=anon`\n- `utm_source` / `utm_medium` / `utm_campaign` - Filter by campaign tags; `(none)` matches untagged clicks\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` - Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` - US/CA clicks from Chrome\n- `{\"short_code\": [\"link1\", \"link2\"]}` - Stats for specific URLs (`scope=all` only)\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below)."
+ },
+ {
+ "name": "browser",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 500
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "Chrome,Firefox"
+ ],
+ "title": "Browser"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "os",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 500
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "Windows,macOS"
+ ],
+ "title": "Os"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "device",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 200
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated device types. Alternative to using the `filters` JSON parameter.\n\n**Values:** `mobile`, `tablet`, `desktop`, `unknown`. `unknown` also matches clicks recorded before device tracking existed.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "mobile,desktop"
+ ],
+ "title": "Device"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated device types. Alternative to using the `filters` JSON parameter.\n\n**Values:** `mobile`, `tablet`, `desktop`, `unknown`. `unknown` also matches clicks recorded before device tracking existed.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "country",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 1000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "United States,Germany"
+ ],
+ "title": "Country"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "city",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 1000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "San Francisco,Berlin"
+ ],
+ "title": "City"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "referrer",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 2000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "https://google.com,https://twitter.com"
+ ],
+ "title": "Referrer"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "utm_source",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 1000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "newsletter,twitter"
+ ],
+ "title": "Utm Source"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "utm_medium",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 1000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "email,social"
+ ],
+ "title": "Utm Medium"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "utm_campaign",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 1000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "summer-launch"
+ ],
+ "title": "Utm Campaign"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StatsResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {},
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "JWTAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/public/stats/{short_code}": {
+ "get": {
+ "tags": [
+ "Public"
+ ],
+ "summary": "Public URL Statistics",
+ "description": "Get public click statistics for a single short link.\n\nResolves both URL generations (plus emoji aliases) on the system\ndefault domain and returns link facts plus the standard stats wire.\n\n**Authentication**: Optional - an owner session additionally sees\nprivate-stats links and skips the password gate.\n\n**Privacy**: A link with private stats answers exactly like a missing\ncode (404, byte-identical). Password-protected links answer 401\n`password_required`; send the password via POST - never in the URL.\n\n**Rate Limits**:\n\n- Authenticated: 60/min, 2,000/day\n- Anonymous: 20/min, 500/day",
+ "operationId": "getPublicStats",
+ "parameters": [
+ {
+ "name": "short_code",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Short Code"
+ }
+ },
+ {
+ "name": "start_date",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 50
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Range start (ISO 8601). Defaults to 7 days before end_date.",
+ "examples": [
+ "2025-01-01T00:00:00Z"
+ ],
+ "title": "Start Date"
+ },
+ "description": "Range start (ISO 8601). Defaults to 7 days before end_date."
+ },
+ {
+ "name": "end_date",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 50
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Range end (ISO 8601). Defaults to now.",
+ "examples": [
+ "2025-12-31T23:59:59Z"
+ ],
+ "title": "End Date"
+ },
+ "description": "Range end (ISO 8601). Defaults to now."
+ },
+ {
+ "name": "timezone",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "maxLength": 50,
+ "description": "IANA timezone for time bucketing and formatting.",
+ "examples": [
+ "UTC",
+ "America/New_York"
+ ],
+ "default": "UTC",
+ "title": "Timezone"
+ },
+ "description": "IANA timezone for time bucketing and formatting."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicStatsResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {},
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "JWTAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Public"
+ ],
+ "summary": "Public URL Statistics (password unlock)",
+ "description": "Same as the GET variant, carrying a password in the JSON body.\n\nThe body is the ONLY way a password travels to this endpoint -\nquery-string passwords are ignored so they can't land in URLs, logs,\nor referrers. Wrong passwords answer 401 `invalid_password`\n(retryable). The body may be absent or empty.",
+ "operationId": "getPublicStatsWithPassword",
+ "parameters": [
+ {
+ "name": "short_code",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Short Code"
+ }
+ },
+ {
+ "name": "start_date",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 50
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Range start (ISO 8601). Defaults to 7 days before end_date.",
+ "examples": [
+ "2025-01-01T00:00:00Z"
+ ],
+ "title": "Start Date"
+ },
+ "description": "Range start (ISO 8601). Defaults to 7 days before end_date."
+ },
+ {
+ "name": "end_date",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 50
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Range end (ISO 8601). Defaults to now.",
+ "examples": [
+ "2025-12-31T23:59:59Z"
+ ],
+ "title": "End Date"
+ },
+ "description": "Range end (ISO 8601). Defaults to now."
+ },
+ {
+ "name": "timezone",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "maxLength": 50,
+ "description": "IANA timezone for time bucketing and formatting.",
+ "examples": [
+ "UTC",
+ "America/New_York"
+ ],
+ "default": "UTC",
+ "title": "Timezone"
+ },
+ "description": "IANA timezone for time bucketing and formatting."
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/PublicStatsBody"
+ },
+ {
+ "type": "null"
+ }
],
- "summary": "Health Check",
- "description": "Check the health of the application and its dependencies.\n\nPings MongoDB and Redis to determine overall system status:\n\n- **healthy** (200): Both MongoDB and Redis are reachable.\n- **degraded** (200): MongoDB is reachable but Redis is down or not configured.\n- **unhealthy** (503): MongoDB is unreachable -- the app cannot function.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: None",
- "operationId": "healthCheck",
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {}
- }
- }
- }
+ "title": "Body"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicStatsResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {},
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "JWTAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/export": {
+ "get": {
+ "tags": [
+ "Statistics"
+ ],
+ "summary": "Export Statistics",
+ "description": "Export URL click statistics as a downloadable file.\n\nGenerate a file export of click analytics data in the specified format.\nThe response is a binary download with appropriate `Content-Disposition` header.\n\n**Authentication**: Optional for `scope=anon` (public stats on a single URL);\nrequired for `scope=all`.\n\n**API Key Scope**: `stats:read`, `urls:read`, or `admin:all`\n\n**Rate Limits**:\n\n- Authenticated: 30/min, 1,000/day\n- Anonymous: 10/min, 200/day\n\n**Export Formats**:\n\n- `json` - single JSON file\n- `xml` - single XML file\n- `xlsx` - Excel spreadsheet with multiple sheets\n- `csv` - **ZIP archive** containing `summary.csv` plus one CSV file per metrics dimension\n\n**Note**: Export generation is resource-intensive. Lower rate limits apply\ncompared to other endpoints.",
+ "operationId": "exportStats",
+ "parameters": [
+ {
+ "name": "scope",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "enum": [
+ "all",
+ "anon"
+ ],
+ "type": "string",
+ "description": "Statistics scope: `all` (authenticated only) or `anon` (public access).\n\n- `all` - aggregate stats across all URLs owned by the authenticated user. Requires authentication.\n- `anon` - public stats for a single URL. Requires `short_code` parameter. No authentication needed (unless stats are private).",
+ "default": "all",
+ "title": "Scope"
+ },
+ "description": "Statistics scope: `all` (authenticated only) or `anon` (public access).\n\n- `all` - aggregate stats across all URLs owned by the authenticated user. Requires authentication.\n- `anon` - public stats for a single URL. Requires `short_code` parameter. No authentication needed (unless stats are private)."
+ },
+ {
+ "name": "short_code",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 50
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "URL alias to query stats for. **Required** when `scope=anon`. When `scope=all`, this is optional and filters stats to a specific URL.",
+ "examples": [
+ "mylink"
+ ],
+ "title": "Short Code"
+ },
+ "description": "URL alias to query stats for. **Required** when `scope=anon`. When `scope=all`, this is optional and filters stats to a specific URL."
+ },
+ {
+ "name": "start_date",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 50
},
- "security": []
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to the URL creation date.",
+ "examples": [
+ "2025-01-01T00:00:00Z"
+ ],
+ "title": "Start Date"
+ },
+ "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to the URL creation date."
+ },
+ {
+ "name": "end_date",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 50
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now.",
+ "examples": [
+ "2025-12-31T23:59:59Z"
+ ],
+ "title": "End Date"
+ },
+ "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now."
+ },
+ {
+ "name": "group_by",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 200
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` - group by time buckets (day/week/month, auto-selected based on range)\n- `browser` - group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` - group by operating system (e.g., Windows, macOS, Linux)\n- `device` - group by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` - group by country\n- `city` - group by city\n- `referrer` - group by referrer URL\n- `short_code` - group by URL alias (only with `scope=all`)\n- `utm_source` - group by the `utm_source` tag on the short link (untagged clicks appear as `(none)`)\n- `utm_medium` - group by the `utm_medium` tag\n- `utm_campaign` - group by the `utm_campaign` tag\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser.",
+ "examples": [
+ "time,browser",
+ "country",
+ "time,country,browser"
+ ],
+ "title": "Group By"
+ },
+ "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` - group by time buckets (day/week/month, auto-selected based on range)\n- `browser` - group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` - group by operating system (e.g., Windows, macOS, Linux)\n- `device` - group by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` - group by country\n- `city` - group by city\n- `referrer` - group by referrer URL\n- `short_code` - group by URL alias (only with `scope=all`)\n- `utm_source` - group by the `utm_source` tag on the short link (untagged clicks appear as `(none)`)\n- `utm_medium` - group by the `utm_medium` tag\n- `utm_campaign` - group by the `utm_campaign` tag\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser."
+ },
+ {
+ "name": "metrics",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 200
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` - total click count\n- `unique_clicks` - unique visitor count (deduplicated by IP + User-Agent)",
+ "examples": [
+ "clicks,unique_clicks",
+ "clicks"
+ ],
+ "title": "Metrics"
+ },
+ "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` - total click count\n- `unique_clicks` - unique visitor count (deduplicated by IP + User-Agent)"
+ },
+ {
+ "name": "timezone",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "maxLength": 50,
+ "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`.",
+ "examples": [
+ "UTC",
+ "America/New_York"
+ ],
+ "default": "UTC",
+ "title": "Timezone"
+ },
+ "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`."
+ },
+ {
+ "name": "filters",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 5000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` - Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` - Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `device` - Filter by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` - Filter by country name (e.g., United States, Canada, Germany)\n- `city` - Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` - Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `short_code` - Filter by URL alias (e.g., mylink, promo2024) - **not allowed** with `scope=anon`\n- `utm_source` / `utm_medium` / `utm_campaign` - Filter by campaign tags; `(none)` matches untagged clicks\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` - Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` - US/CA clicks from Chrome\n- `{\"short_code\": [\"link1\", \"link2\"]}` - Stats for specific URLs (`scope=all` only)\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below).",
+ "examples": [
+ "{\"browser\":[\"Chrome\",\"Firefox\"]}",
+ "{\"country\":[\"United States\",\"Canada\"],\"browser\":[\"Chrome\"]}"
+ ],
+ "title": "Filters"
+ },
+ "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` - Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` - Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `device` - Filter by device type (`mobile`, `tablet`, `desktop`, `unknown`)\n- `country` - Filter by country name (e.g., United States, Canada, Germany)\n- `city` - Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` - Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `short_code` - Filter by URL alias (e.g., mylink, promo2024) - **not allowed** with `scope=anon`\n- `utm_source` / `utm_medium` / `utm_campaign` - Filter by campaign tags; `(none)` matches untagged clicks\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` - Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` - US/CA clicks from Chrome\n- `{\"short_code\": [\"link1\", \"link2\"]}` - Stats for specific URLs (`scope=all` only)\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below)."
+ },
+ {
+ "name": "browser",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 500
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "Chrome,Firefox"
+ ],
+ "title": "Browser"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "os",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 500
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "Windows,macOS"
+ ],
+ "title": "Os"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "device",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 200
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated device types. Alternative to using the `filters` JSON parameter.\n\n**Values:** `mobile`, `tablet`, `desktop`, `unknown`. `unknown` also matches clicks recorded before device tracking existed.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "mobile,desktop"
+ ],
+ "title": "Device"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated device types. Alternative to using the `filters` JSON parameter.\n\n**Values:** `mobile`, `tablet`, `desktop`, `unknown`. `unknown` also matches clicks recorded before device tracking existed.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "country",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 1000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "United States,Germany"
+ ],
+ "title": "Country"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "city",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 1000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "San Francisco,Berlin"
+ ],
+ "title": "City"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "referrer",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 2000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "https://google.com,https://twitter.com"
+ ],
+ "title": "Referrer"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "utm_source",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 1000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "newsletter,twitter"
+ ],
+ "title": "Utm Source"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "utm_medium",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 1000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "email,social"
+ ],
+ "title": "Utm Medium"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "utm_campaign",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 1000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
+ "examples": [
+ "summer-launch"
+ ],
+ "title": "Utm Campaign"
+ },
+ "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated campaign tag values. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. `(none)` matches clicks with no tag.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
+ },
+ {
+ "name": "format",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "enum": [
+ "csv",
+ "xlsx",
+ "json",
+ "xml"
+ ],
+ "type": "string",
+ "description": "Export file format.",
+ "title": "Format"
+ },
+ "description": "Export file format."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Export file download",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ },
+ "application/xml": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ },
+ "application/zip": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ },
+ "x-description": "CSV export - ZIP archive containing summary.csv plus one file per dimension"
+ },
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ },
+ "x-description": "XLSX export - Excel workbook with multiple sheets"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error - export generation failed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {},
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "JWTAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/keys": {
+ "get": {
+ "tags": [
+ "API Keys"
+ ],
+ "summary": "List API Keys",
+ "description": "List all API keys for the authenticated user.\n\nReturns metadata for all API keys (both active and revoked) belonging to the\nauthenticated user. The full token value is **never** returned in this\nendpoint for security reasons - only the `token_prefix` is shown.\n\n**Authentication**: Required - interactive session or an app token with\nthe `keys:manage` scope (API keys cannot be used to manage API keys).\n\n**Rate Limits**: 60/min",
+ "operationId": "listApiKeys",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiKeysListResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "API Keys"
+ ],
+ "summary": "Create API Key",
+ "description": "Create a new API key for programmatic access.\n\nGenerate a new API key with the specified name and scopes. The full token\n(prefixed with `spoo_`) is returned **only in this response** and cannot be\nretrieved again.\n\n**Authentication**: Required - interactive session only. Connected apps\nand API keys cannot create keys; minting a credential is a first-party\naction. Email must be verified.\n\n**Rate Limits**: 5/hour\n\n**Available Scopes**: `shorten:create`, `urls:manage`, `urls:read`,\n`stats:read`, `domains:manage`, `domains:read`, `reports:create`,\n`admin:all`\n\n**Notes**:\n\n- Store the returned `token` securely - it will not be shown again\n- Set `expires_at` to limit the key's lifetime (ISO 8601 or Unix epoch)\n- Omit `expires_at` for a non-expiring key",
+ "operationId": "createApiKey",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateApiKeyRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiKeyCreatedResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/keys/{key_id}": {
+ "delete": {
+ "tags": [
+ "API Keys"
+ ],
+ "summary": "Delete/Revoke API Key",
+ "description": "Delete or revoke an API key.\n\nRemove an API key either permanently (hard delete) or by marking it as\nrevoked (soft delete). Revoked keys stop working immediately but remain\nvisible in the key list for audit purposes.\n\n**Authentication**: Required - interactive session or an app token with\nthe `keys:manage` scope (API keys cannot be used to manage API keys).\n\n**Rate Limits**: 30/min\n\n**Modes**:\n\n- `?revoke=false` (default) - **permanently deletes** the key record\n- `?revoke=true` - marks the key as revoked but preserves the record;\n the key appears with `revoked: true` in the list endpoint",
+ "operationId": "deleteApiKey",
+ "parameters": [
+ {
+ "name": "key_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 24,
+ "maxLength": 24,
+ "pattern": "^[0-9a-f]{24}$",
+ "title": "Key Id"
+ }
+ },
+ {
+ "name": "revoke",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "boolean",
+ "default": false,
+ "title": "Revoke"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiKeyActionResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/apps": {
+ "get": {
+ "tags": [
+ "Apps"
+ ],
+ "summary": "List Connected Apps",
+ "description": "List the apps connected to the authenticated user's account.\n\nReturns one entry per active device-auth grant (revoked grants are\nexcluded), newest first. `app` is the registry key from\n`config/apps.yaml` - pass it as `app_id` to `POST /auth/device/revoke`\nto disconnect an app. An empty `items` array means nothing is\nconnected.\n\n**Authentication**: Required - JWT Bearer only (API keys cannot list\nthe account's connected apps).\n\n**Rate Limits**: 60/min",
+ "operationId": "listAppGrants",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AppGrantsListResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/custom-domains": {
+ "post": {
+ "tags": [
+ "Custom Domains"
+ ],
+ "summary": "Register Custom Domain",
+ "description": "Register a new custom domain for branded short links.\n\nThe domain is born in `PENDING` state. The user must publish the DNS\nrecords returned in `dns_records` at their DNS provider, then call\n`POST /custom-domains/{id}/verify` to trigger verification. Cloudflare\nauto-verifies via HTTP DCV once the CNAME is live and propagated.\n\n**Authentication**: Required (JWT Bearer or API key with `domains:manage`).\n\n**Email verification**: Required (applies to API key callers too).\n\n**Feature gate**: Must be enabled for the calling user.\n\n**Rate Limits**: 10/hour (route, failed attempts count) + `max_per_user`\nowned domains (service quota, any status).",
+ "operationId": "createCustomDomain",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateCustomDomainRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CustomDomainResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "get": {
+ "tags": [
+ "Custom Domains"
+ ],
+ "summary": "List Custom Domains",
+ "description": "List custom domains owned by the authenticated user.\n\nReads bypass the feature flag so owners can still see state during a\nrollback.\n\n**Authentication**: Required (JWT or API key with `domains:read`).\n\n**Rate Limits**: 60/min.\n\n**Pagination**: `page` (default 1) + `pageSize` (default 20, max 100).",
+ "operationId": "listCustomDomains",
+ "parameters": [
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 1000,
+ "minimum": 1,
+ "default": 1,
+ "title": "Page"
+ }
+ },
+ {
+ "name": "page_size",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 100,
+ "minimum": 1,
+ "default": 20,
+ "title": "Page Size"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CustomDomainListResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/custom-domains/{domain_id}/verify": {
+ "post": {
+ "tags": [
+ "Custom Domains"
+ ],
+ "summary": "Verify Custom Domain",
+ "description": "Trigger a fresh verification check for a custom domain.\n\nThe verifier dispatched depends on the chosen DCV method (CF HTTP DCV on\nCF SaaS deployments, CNAME/A/TXT on self-host). On success the domain\ntransitions to `ACTIVE` and short links on the domain start resolving.\n\n**Authentication**: Required (JWT or API key with `domains:manage`).\n\n**Rate Limits**: 10/min (route) + 60/hour per domain (service quota).",
+ "operationId": "verifyCustomDomain",
+ "parameters": [
+ {
+ "name": "domain_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "MongoDB ObjectId of the domain.",
+ "title": "Domain Id"
+ },
+ "description": "MongoDB ObjectId of the domain."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CustomDomainResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/custom-domains/{domain_id}": {
+ "get": {
+ "tags": [
+ "Custom Domains"
+ ],
+ "summary": "Get Custom Domain",
+ "description": "Fetch a single custom domain owned by the caller.\n\nUsed by the dashboard detail view + auto-poll after Verify. Bypasses the\nfeature flag like other read endpoints.\n\n**Authentication**: Required (JWT or API key with `domains:read`).\n\n**Rate Limits**: 60/min.",
+ "operationId": "getCustomDomain",
+ "parameters": [
+ {
+ "name": "domain_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "MongoDB ObjectId of the domain.",
+ "title": "Domain Id"
+ },
+ "description": "MongoDB ObjectId of the domain."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CustomDomainResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "Custom Domains"
+ ],
+ "summary": "Update Custom Domain Routing",
+ "description": "Update the per-domain routing config.\n\nPartial update - send only the fields you want to change. Omit a field to\nleave it untouched. Send explicit `null` to clear a stored value. The\nconfig takes effect only when the domain is `ACTIVE`; non-ACTIVE domains\nreturn 422.\n\n**Configurable fields**:\n- `root_redirect` - destination for `GET /` (302).\n- `not_found_redirect` - fallback for any path that doesn't match an alias.\n- `custom_robots_txt` - body served at `/robots.txt` (≤4096 chars).\n\n**Authentication**: Required (JWT or API key with `domains:manage`).\n\n**Rate Limits**: 30/min.\n\n**Responses**:\n- 200 - updated; full domain response returned\n- 403 - caller does not own this domain\n- 404 - domain not found (or invalid id)\n- 422 - domain isn't ACTIVE, or body fails validation",
+ "operationId": "updateCustomDomain",
+ "parameters": [
+ {
+ "name": "domain_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "MongoDB ObjectId of the domain.",
+ "title": "Domain Id"
+ },
+ "description": "MongoDB ObjectId of the domain."
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateCustomDomainRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CustomDomainResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Custom Domains"
+ ],
+ "summary": "Revoke Custom Domain",
+ "description": "Revoke a custom domain. `REVOKED` is terminal.\n\nWith `?cascade=true`, all URLs owned by the caller on the domain are\nbulk-deleted. With `?cascade=false` (default), URLs remain in the\ndatabase and the domain stops serving - the URLs effectively become\norphans until the domain is re-registered or garbage-collected.\n\n**Authentication**: Required (JWT or API key with `domains:manage`).\n\n**Rate Limits**: 10/min.",
+ "operationId": "deleteCustomDomain",
+ "parameters": [
+ {
+ "name": "domain_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "MongoDB ObjectId of the domain.",
+ "title": "Domain Id"
+ },
+ "description": "MongoDB ObjectId of the domain."
+ },
+ {
+ "name": "cascade",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "boolean",
+ "description": "When true, all URLs on this domain are deleted alongside the domain revoke. When false (default), URLs remain in the database but become unreachable (the domain stops resolving).",
+ "default": false,
+ "title": "Cascade"
+ },
+ "description": "When true, all URLs on this domain are deleted alongside the domain revoke. When false (default), URLs remain in the database but become unreachable (the domain stops resolving)."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CustomDomainDeleteResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/custom-domains/{domain_id}/permanent": {
+ "delete": {
+ "tags": [
+ "Custom Domains"
+ ],
+ "summary": "Remove Revoked Custom Domain",
+ "description": "Permanently delete a revoked custom domain to free the account slot.\n\nSoft-deleted (REVOKED) domains continue to occupy the caller's per-account\nquota so revoke is reversible and the audit trail stays intact. To free\nthe slot, the caller explicitly removes the doc via this endpoint. Only\nREVOKED docs are removable - call DELETE first to revoke an active one.\n\n**Authentication**: Required (JWT or API key with `domains:manage`).\n\n**Rate Limits**: 10/min.\n\n**Responses**:\n- 204 - removed\n- 403 - caller does not own this domain\n- 404 - domain not found (or invalid id)\n- 422 - domain isn't REVOKED",
+ "operationId": "removeCustomDomain",
+ "parameters": [
+ {
+ "name": "domain_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "MongoDB ObjectId of the domain.",
+ "title": "Domain Id"
+ },
+ "description": "MongoDB ObjectId of the domain."
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Successful Response"
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/metadata": {
+ "get": {
+ "tags": [
+ "Metadata"
+ ],
+ "summary": "Fetch Destination Meta Tags",
+ "description": "Fetch a destination page and return its existing meta tags.\n\nUse this to prefill ``meta_tags`` before customizing a link's social\npreview. Returns normalized best-pick fields (og → twitter → html\nfallbacks) plus the raw ``og``/``twitter`` tag families.\n\n**Authentication**: Required. **API Key Scope**: `urls:read`,\n`urls:manage`, or `admin:all`.\n\n**Rate Limits**: 20/min, 500/day - results are cached ~1h server-side,\nso repeat calls for the same URL are cheap and don't refetch.",
+ "operationId": "getUrlMetadata",
+ "parameters": [
+ {
+ "name": "url",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "maxLength": 2048,
+ "description": "Destination https URL to fetch and parse.",
+ "examples": [
+ "https://example.com/article"
+ ],
+ "title": "Url"
+ },
+ "description": "Destination https URL to fetch and parse."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MetadataResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/me/features": {
+ "get": {
+ "tags": [
+ "Me"
+ ],
+ "summary": "Get Feature Availability",
+ "description": "Return the availability state of every gated feature for this account.\n\nStates: `enabled` (render it), `hidden` (the feature doesn't exist for\nthis account), `locked` (reserved - render as upgrade-gated once plans\nship). Treat features missing from the map as `hidden`. Never used for\nenforcement - the write endpoints enforce the same gates server-side.\n\n**Authentication**: Required.",
+ "operationId": "getMyFeatures",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/FeaturesResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/me/layouts/{page}": {
+ "get": {
+ "tags": [
+ "Me"
+ ],
+ "summary": "Get Page Layout",
+ "description": "Fetch the saved dashboard layout for a page.\n\nReturns `layout: null` when the user has never customized this page -\nclients render their built-in default in that case.\n\n**Authentication**: Required.",
+ "operationId": "getPageLayout",
+ "parameters": [
+ {
+ "name": "page",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "enum": [
+ "analytics",
+ "overview"
+ ],
+ "type": "string",
+ "description": "Layout slot, e.g. `analytics`",
+ "title": "Page"
+ },
+ "description": "Layout slot, e.g. `analytics`"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/LayoutResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "tags": [
+ "Me"
+ ],
+ "summary": "Save Page Layout",
+ "description": "Save the layout document for a page.\n\nThe document is stored verbatim (last write wins) and echoed back.\nVersioning and validation are the client's responsibility; the body is\ncapped at 32 KiB.\n\n**Authentication**: Required.",
+ "operationId": "putPageLayout",
+ "parameters": [
+ {
+ "name": "page",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "enum": [
+ "analytics",
+ "overview"
+ ],
+ "type": "string",
+ "description": "Layout slot, e.g. `analytics`",
+ "title": "Page"
+ },
+ "description": "Layout slot, e.g. `analytics`"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PutLayoutRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/LayoutResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Me"
+ ],
+ "summary": "Reset Page Layout",
+ "description": "Remove the saved layout so the page falls back to the client default.\n\nIdempotent: returns 204 whether or not an override existed.\n\n**Authentication**: Required.",
+ "operationId": "deletePageLayout",
+ "parameters": [
+ {
+ "name": "page",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "enum": [
+ "analytics",
+ "overview"
+ ],
+ "type": "string",
+ "description": "Layout slot, e.g. `analytics`",
+ "title": "Page"
+ },
+ "description": "Layout slot, e.g. `analytics`"
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Successful Response"
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/me/profile-pictures": {
+ "get": {
+ "tags": [
+ "Me"
+ ],
+ "summary": "Get Available Profile Pictures",
+ "description": "List the profile pictures available to this account.\n\nOne entry per linked OAuth provider picture, with `is_current` marking\nthe active one.\n\n**Authentication**: Required.",
+ "operationId": "getMyProfilePictures",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AvailablePicturesResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "Me"
+ ],
+ "summary": "Set Profile Picture",
+ "description": "Set the profile picture to one of the available provider pictures.\n\n`picture_id` must be an id returned by the GET endpoint; unknown ids\nyield 404.\n\n**Authentication**: Required.",
+ "operationId": "setMyProfilePicture",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SetProfilePictureRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProfilePictureMessageResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
}
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Me"
+ ],
+ "summary": "Remove Profile Picture",
+ "description": "Unset the profile picture so the account falls back to the default.\n\nIdempotent: returns 200 whether or not a picture was set.\n\n**Authentication**: Required.",
+ "operationId": "deleteMyProfilePicture",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProfilePictureMessageResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/me/profile-pictures/upload": {
+ "post": {
+ "tags": [
+ "Me"
+ ],
+ "summary": "Upload Profile Picture",
+ "description": "Upload a custom profile picture as a base64 data URI.\n\nAccepts image/png, image/jpeg and image/webp; size and content-type\nvalidation happens server-side against the configured upload cap.\n\n**Authentication**: Required.",
+ "operationId": "uploadMyProfilePicture",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UploadProfilePictureRequest"
+ }
+ }
+ },
+ "required": true
},
- "/auth/login": {
- "post": {
- "tags": [
- "Authentication"
- ],
- "summary": "Login",
- "description": "Authenticate with email and password.\n\nReturns JWT access token and sets secure HTTP-only cookies for both\naccess and refresh tokens. The refresh token can be used at\n``POST /auth/refresh`` to obtain new tokens without re-authenticating.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 5/min, 50/day\n\n**Security**: Returns identical error for wrong email and wrong password\nto prevent user enumeration.",
- "operationId": "loginUser",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/LoginRequest"
- }
- }
- },
- "required": true
- },
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/LoginResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- },
- "security": []
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProfilePictureMessageResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
}
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/public/preview/{short_code}": {
+ "get": {
+ "tags": [
+ "Public"
+ ],
+ "summary": "Public Link Preview",
+ "description": "Preview where a short link leads before following it.\n\nReturns the link's resolved facts - status, creation date, whether a\npassword gates it, and (only while the link is active and unlocked)\nthe destination plus every geo-targeted destination, grouped by URL.\n\n**Authentication**: None. The preview shows the same resolved facts to\neveryone; owner-set social meta never appears here.\n\n**Rate Limits**: 30/min, 2,000/day.",
+ "operationId": "getPublicPreview",
+ "parameters": [
+ {
+ "name": "short_code",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Short Code"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicPreviewResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
},
- "/auth/register": {
- "post": {
- "tags": [
- "Authentication"
- ],
- "summary": "Register",
- "description": "Create a new user account with email and password.\n\nImmediately signs the user in by returning a JWT access token and setting\nsecure HTTP-only cookies. A verification email is sent best-effort; the\n``verification_sent`` field indicates whether it succeeded.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 5/min, 50/day\n\n**Notes**: The account is created even if the verification email fails.\nThe user must verify their email before accessing protected resources.",
- "operationId": "registerUser",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/RegisterRequest"
- }
- }
- },
- "required": true
- },
- "responses": {
- "201": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/RegisterResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "409": {
- "description": "Conflict \u2014 resource already exists",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- },
- "security": []
+ "security": []
+ }
+ },
+ "/api/v1/reports": {
+ "post": {
+ "tags": [
+ "Reports & Contact"
+ ],
+ "summary": "Report URLs",
+ "description": "Report shortened URLs for abuse - one or many per request.\n\nAccepts bare codes or full short URLs (custom domains included) and\nreturns a per-item accepted/rejected breakdown - bad codes don't sink\nthe batch. Re-reports of a code increment its counter rather than\nfiling duplicates.\n\n**Authentication**: Optional. Anonymous submissions are captcha-gated\nand capped at **25 items/request**; authenticated callers (session or\nAPI key) skip the captcha and may send **100 items/request**.\n\n**API Key Scope**: `reports:create` or `admin:all`\n\n**Rate Limits** (per submission, not per item):\n\n- Authenticated: 30/min, 500/day\n- Anonymous: 5/min, 40/day",
+ "operationId": "submitReports",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateReportsRequest"
+ }
}
+ },
+ "required": true
},
- "/auth/refresh": {
- "post": {
- "tags": [
- "Authentication"
- ],
- "summary": "Refresh Tokens",
- "description": "Rotate the access and refresh token pair.\n\nReads the ``refresh_token`` cookie, validates it, and issues a new\naccess/refresh pair. Both cookies are replaced. If the refresh token\nis missing, expired, or invalid, all auth cookies are cleared and a\n401 response is returned.\n\n**Authentication**: Requires a valid ``refresh_token`` cookie\n\n**Rate Limits**: 20/min\n\n**Notes**: The old refresh token is invalidated after use (rotation).",
- "operationId": "refreshTokens",
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/RefreshResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- }
- },
- "security": []
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ReportSubmissionResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
}
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Not configured - the required webhook is unset on this instance",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
},
- "/auth/logout": {
- "post": {
- "tags": [
- "Authentication"
- ],
- "summary": "Logout",
- "description": "Log the current user out by clearing auth cookies.\n\nRemoves the ``access_token`` and ``refresh_token`` HTTP-only cookies.\nAlways succeeds regardless of whether the user was authenticated.\n\n**Authentication**: Not required\n\n**Rate Limits**: 60/hour",
- "operationId": "logout",
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/LogoutResponse"
- }
- }
- }
- }
- }
- }
- },
- "/auth/me": {
- "get": {
- "tags": [
- "Authentication"
- ],
- "summary": "Get Current User",
- "description": "Return the authenticated user's full profile.\n\nIncludes email, verification status, linked OAuth providers, plan,\nand profile picture. Useful for populating the UI after login or\non page load.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 60/min",
- "operationId": "getCurrentUser",
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/MeResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- }
- }
- }
- },
- "/auth/set-password": {
- "post": {
- "tags": [
- "Authentication"
- ],
- "summary": "Set Password",
- "description": "Set a password for an OAuth-only account.\n\nAllows users who signed up via OAuth to add a password so they can\nalso log in with email + password. Fails if the user already has a\npassword set.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 5/min",
- "operationId": "setPassword",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/SetPasswordRequest"
- }
- }
- },
- "required": true
- },
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/MessageResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- }
- }
- },
- "/auth/send-verification": {
- "post": {
- "tags": [
- "Authentication"
- ],
- "summary": "Send Verification Email",
- "description": "Send a 6-digit OTP verification code to the user's email.\n\nThe code expires after the duration returned in ``expires_in`` (seconds).\nIf the user is already verified, the request still succeeds but no email\nis sent.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 3/hour\n\n**Notes**: Previous unused OTPs are invalidated when a new one is sent.",
- "operationId": "sendVerification",
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/SendVerificationResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- }
- }
- }
- },
- "/auth/verify-email": {
- "post": {
- "tags": [
- "Authentication"
- ],
- "summary": "Verify Email",
- "description": "Verify the user's email address using a 6-digit OTP code.\n\nOn success, new JWT tokens are issued with ``email_verified=true`` in the\nclaims, and auth cookies are updated. A welcome email is sent best-effort.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 10/hour\n\n**Notes**: The OTP must match the most recently sent code and must not\nbe expired. Expired or already-used codes are rejected.",
- "operationId": "verifyEmail",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/VerifyEmailRequest"
- }
- }
- },
- "required": true
- },
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/VerifyEmailResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- }
- }
- },
- "/auth/request-password-reset": {
- "post": {
- "tags": [
- "Authentication"
- ],
- "summary": "Request Password Reset",
- "description": "Request a password-reset OTP to be sent via email.\n\nAlways returns the same success response regardless of whether the\nemail is registered. This prevents user enumeration attacks.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 3/hour\n\n**Security**: Timing-safe -- response time is constant whether or not\nthe account exists.",
- "operationId": "requestPasswordReset",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/RequestPasswordResetRequest"
- }
- }
- },
- "required": true
- },
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/MessageResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
+ "security": [
+ {},
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "JWTAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/contact": {
+ "post": {
+ "tags": [
+ "Reports & Contact"
+ ],
+ "summary": "Contact",
+ "description": "Send a message to the site operators.\n\nJSON twin of the ``/contact`` form - same service, same webhook, same\nbudget values.\n\n**Authentication**: None.\n\n**Rate Limits**: 5/min, 20/hour, 50/day",
+ "operationId": "submitContactMessage",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ContactRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ContactOkResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Not configured - the required webhook is unset on this instance",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ },
+ "security": []
+ }
+ },
+ "/api/v1/webhooks/event-types": {
+ "get": {
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "List Webhook Event Types",
+ "description": "The public webhook event catalog - documentation as API.\n\nEvery subscribable event type with its category, firing frequency, and\nan exact sample payload (the same fixture test sends deliver).\n\n**Authentication**: None.",
+ "operationId": "listWebhookEventTypes",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EventTypesResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/webhooks": {
+ "get": {
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "List Webhook Endpoints",
+ "description": "List all webhook endpoints on the account (secrets never included).",
+ "operationId": "listWebhookEndpoints",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookEndpointsListResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "Create Webhook Endpoint",
+ "description": "Register an HTTPS endpoint to receive event deliveries.\n\nThe `signing_secret` is returned **only in this response** - store it;\nit is what your server uses to verify `webhook-signature` headers\n(Standard Webhooks, HMAC-SHA256).\n\n**Authentication**: Required (verified email). Feature-flagged.\n\n**Rate Limits**: 10/hour",
+ "operationId": "createWebhookEndpoint",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateWebhookEndpointRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookEndpointCreatedResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/webhooks/{endpoint_id}": {
+ "get": {
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "Get Webhook Endpoint",
+ "operationId": "getWebhookEndpoint",
+ "parameters": [
+ {
+ "name": "endpoint_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Endpoint Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookEndpointResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "Update Webhook Endpoint",
+ "description": "Update url, events, scope, flavor, description, or pause/resume.\n\nA new `url` is re-checked (https + public address) before it takes\neffect. `status` accepts `active`/`paused`; `disabled` is system-set -\nre-activating a disabled endpoint clears its failure bookkeeping.",
+ "operationId": "updateWebhookEndpoint",
+ "parameters": [
+ {
+ "name": "endpoint_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Endpoint Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateWebhookEndpointRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookEndpointResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "Delete Webhook Endpoint",
+ "operationId": "deleteWebhookEndpoint",
+ "parameters": [
+ {
+ "name": "endpoint_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Endpoint Id"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Successful Response"
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/webhooks/{endpoint_id}/secret": {
+ "get": {
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "Reveal Signing Secret",
+ "description": "The full signing secret, for the endpoint owner. Secrets are stored\nencrypted and readable on demand; treat the response like a password.\n\n**Authentication**: Interactive session only - API keys are refused,\nlike every operation that exposes or mints credential material.",
+ "operationId": "revealWebhookSecret",
+ "parameters": [
+ {
+ "name": "endpoint_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Endpoint Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookSecretResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/webhooks/{endpoint_id}/test": {
+ "post": {
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "Send Test Event",
+ "description": "Send a sample of any catalog event through the real pipeline -\nrendered in the endpoint's flavor, signed with its real secret - and\nreturn the delivery outcome synchronously.",
+ "operationId": "testWebhookEndpoint",
+ "parameters": [
+ {
+ "name": "endpoint_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Endpoint Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TestWebhookRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookDeliveryResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/webhooks/{endpoint_id}/deliveries": {
+ "get": {
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "List Webhook Deliveries",
+ "description": "The delivery log: what was sent, when, each attempt's outcome, and\nthe exact rendered body (30-day retention).",
+ "operationId": "listWebhookDeliveries",
+ "parameters": [
+ {
+ "name": "endpoint_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Endpoint Id"
+ }
+ },
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "default": 1,
+ "title": "Page"
+ }
+ },
+ {
+ "name": "page_size",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 100,
+ "minimum": 1,
+ "default": 25,
+ "title": "Page Size"
+ }
+ },
+ {
+ "name": "status",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/DeliveryStatus"
},
- "security": []
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Status"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeliveriesListResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
}
- },
- "/auth/reset-password": {
- "post": {
- "tags": [
- "Authentication"
- ],
- "summary": "Reset Password",
- "description": "Reset the account password using a 6-digit OTP code.\n\nThe OTP must have been requested via ``POST /auth/request-password-reset``.\nOn success the password is updated immediately and the OTP is consumed.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 5/hour\n\n**Notes**: Expired or already-used OTPs are rejected with a 400 error.",
- "operationId": "resetPassword",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ResetPasswordRequest"
- }
- }
- },
- "required": true
- },
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/MessageResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- },
- "security": []
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
}
- },
- "/oauth/providers": {
- "get": {
- "tags": [
- "OAuth"
- ],
- "summary": "List OAuth Providers",
- "description": "List all OAuth providers linked to the authenticated user's account.\n\nReturns each linked provider's name, email, and link date, plus whether\nthe user has a password set (needed by the UI to decide if unlinking\nthe last provider is allowed).\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 60/min",
- "operationId": "listOAuthProviders",
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/OAuthProvidersResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- }
- }
- }
- },
- "/oauth/providers/{provider_name}/unlink": {
- "delete": {
- "tags": [
- "OAuth"
- ],
- "summary": "Unlink OAuth Provider",
- "description": "Remove an OAuth provider link from the authenticated user's account.\n\nFails if the provider is the user's only authentication method (i.e.,\nno password set and no other providers linked).\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 5/min",
- "operationId": "unlinkOAuthProvider",
- "parameters": [
- {
- "name": "provider_name",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "title": "Provider Name"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/MessageResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- }
- }
- },
- "/oauth/{provider}": {
- "get": {
- "tags": [
- "OAuth"
- ],
- "summary": "OAuth Login",
- "description": "Initiate the OAuth authorization flow for the given provider.\n\nRedirects the user to the provider's consent screen (e.g., Google,\nGitHub). After the user grants access, the provider redirects back\nto ``GET /oauth/{provider}/callback``.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 10/min\n\n**Supported providers**: google, github (configurable)",
- "operationId": "initiateOAuthLogin",
- "parameters": [
- {
- "name": "provider",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "title": "Provider"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {}
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- },
- "security": []
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
}
- },
- "/oauth/{provider}/callback": {
- "get": {
- "tags": [
- "OAuth"
- ],
- "summary": "OAuth Callback",
- "description": "Handle the OAuth provider callback after user authorization.\n\nValidates the CSRF state parameter, exchanges the authorization code for\nan access token, fetches the user's profile from the provider, and then\neither logs in an existing user or creates a new account. On success,\nredirects to ``/dashboard`` with JWT cookies set.\n\n**Authentication**: Not required (public endpoint)\n\n**Rate Limits**: 20/min\n\n**Notes**: This endpoint is called by the OAuth provider, not directly\nby the client. The ``state`` query parameter is required for CSRF protection.",
- "operationId": "oauthCallback",
- "parameters": [
- {
- "name": "provider",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "title": "Provider"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {}
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- },
- "security": []
+ }
+ }
+ }
+ },
+ "/api/v1/webhooks/{endpoint_id}/deliveries/{delivery_id}/retry": {
+ "post": {
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "Retry Delivery",
+ "description": "Redeliver a completed delivery: same `webhook-id`, same body, fresh\nattempt - consumers dedup on `webhook-id`.",
+ "operationId": "retryWebhookDelivery",
+ "parameters": [
+ {
+ "name": "endpoint_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Endpoint Id"
}
- },
- "/oauth/{provider}/link": {
- "get": {
- "tags": [
- "OAuth"
- ],
- "summary": "Link OAuth Provider",
- "description": "Initiate an OAuth flow to link a provider to the authenticated account.\n\nSimilar to ``GET /oauth/{provider}`` but includes the user's ID in the\nstate token so the callback knows to link rather than log in. The user\nmust already be authenticated.\n\n**Authentication**: Required (JWT or API key)\n\n**Rate Limits**: 5/min",
- "operationId": "linkOAuthProvider",
- "parameters": [
- {
- "name": "provider",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "title": "Provider"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {}
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- }
- }
- },
- "/api/v1/shorten": {
- "post": {
- "tags": [
- "URL Shortening"
- ],
- "summary": "Create Shortened URL",
- "description": "Create a new shortened URL.\n\nCreate a shortened URL with optional customization including password protection,\nexpiration, click limits, and bot blocking.\n\n**Authentication**: Optional \u2014 higher rate limits when authenticated.\n\n**API Key Scope**: `shorten:create` or `admin:all`\n\n**Rate Limits**:\n- Authenticated: 60/min, 5,000/day\n- Anonymous: 20/min, 1,000/day\n\n**Anonymous Usage Consequences**:\n- Lower rate limits\n- Cannot manage or view URLs later\n- Cannot use private stats\n- URLs not linked to any account",
- "operationId": "shortenUrl",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/CreateUrlRequest"
- }
- }
- },
- "required": true
- },
- "responses": {
- "201": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/UrlResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "409": {
- "description": "Conflict \u2014 resource already exists",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- },
- "security": [
- {},
- {
- "ApiKeyAuth": []
- },
- {
- "JWTAuth": []
- }
- ]
+ },
+ {
+ "name": "delivery_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Delivery Id"
}
- },
- "/api/v1/urls": {
- "get": {
- "tags": [
- "Link Management"
- ],
- "summary": "List Your URLs",
- "description": "List all URLs owned by the authenticated user.\n\nReturns a paginated list of shortened URLs with support for filtering,\nsorting, and full-text search on aliases and destination URLs.\n\n**Authentication**: Required.\n\n**API Key Scope**: `urls:manage`, `urls:read`, or `admin:all`\n\n**Rate Limits**: 60/min, 5,000/day\n\n**Pagination**: Use `page` and `pageSize` query params. Response includes\n`hasNext` boolean and `total` count.\n\n**Sorting**: Sort by `created_at`, `last_click`, or `total_clicks` in\nascending or descending order.\n\n**Filtering**: Pass a JSON-encoded `filter` parameter with fields like\n`status`, `createdAfter`, `createdBefore`, `passwordSet`, `maxClicksSet`,\nand `search`.",
- "operationId": "listUrls",
- "parameters": [
- {
- "name": "page",
- "in": "query",
- "required": false,
- "schema": {
- "type": "integer",
- "minimum": 1,
- "description": "Page number (default: 1)",
- "examples": [
- 1
- ],
- "default": 1,
- "title": "Page"
- },
- "description": "Page number (default: 1)"
- },
- {
- "name": "pageSize",
- "in": "query",
- "required": false,
- "schema": {
- "type": "integer",
- "maximum": 100,
- "minimum": 1,
- "description": "Items per page (default: 20, max: 100)",
- "examples": [
- 20
- ],
- "default": 20,
- "title": "Pagesize"
- },
- "description": "Items per page (default: 20, max: 100)"
- },
- {
- "name": "sortBy",
- "in": "query",
- "required": false,
- "schema": {
- "enum": [
- "created_at",
- "last_click",
- "total_clicks"
- ],
- "type": "string",
- "description": "Field to sort by",
- "default": "created_at",
- "title": "Sortby"
- },
- "description": "Field to sort by"
- },
- {
- "name": "sortOrder",
- "in": "query",
- "required": false,
- "schema": {
- "enum": [
- "ascending",
- "asc",
- "1",
- "descending",
- "desc",
- "-1"
- ],
- "type": "string",
- "description": "Sort direction",
- "default": "descending",
- "title": "Sortorder"
- },
- "description": "Sort direction"
- },
- {
- "name": "filter",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "JSON string containing filter criteria for URLs. Format: `{\"field\": value}`\n\n**Available filter fields:**\n\n- **status** \u2014 Filter by URL status (`\"ACTIVE\"` or `\"INACTIVE\"`)\n- **createdAfter** \u2014 Filter URLs created after this date (ISO 8601 datetime or Unix timestamp)\n- **createdBefore** \u2014 Filter URLs created before this date (ISO 8601 datetime or Unix timestamp)\n- **passwordSet** \u2014 Filter by password protection (boolean: `true`/`false`)\n- **maxClicksSet** \u2014 Filter by click limit presence (boolean: `true`/`false`)\n- **search** \u2014 Search in alias or long_url (case-insensitive string)\n\n**Value formats:**\n\n- **status**: String \u2014 `\"ACTIVE\"` or `\"INACTIVE\"` (case-sensitive)\n- **createdAfter / createdBefore**: ISO 8601 datetime string (e.g., `\"2024-01-01T00:00:00Z\"`) or Unix timestamp (e.g., `1704067200`)\n- **passwordSet / maxClicksSet**: Boolean \u2014 `true` or `false`\n- **search**: String \u2014 case-insensitive search term\n\n**Examples:**\n\n- `{\"status\": \"ACTIVE\"}` \u2014 Only active URLs\n- `{\"passwordSet\": true}` \u2014 Only password-protected URLs\n- `{\"createdAfter\": \"2024-01-01T00:00:00Z\"}` \u2014 URLs created after Jan 1, 2024\n- `{\"status\": \"ACTIVE\", \"maxClicksSet\": true}` \u2014 Active URLs with click limits\n- `{\"search\": \"example\"}` \u2014 URLs containing \"example\" in alias or long_url\n- `{\"createdAfter\": \"2024-01-01\", \"createdBefore\": \"2024-12-31\", \"status\": \"ACTIVE\"}` \u2014 Active URLs from 2024",
- "examples": [
- "{\"status\":\"ACTIVE\"}",
- "{\"passwordSet\": true}",
- "{\"createdAfter\": \"2024-01-01T00:00:00Z\"}",
- "{\"status\": \"ACTIVE\", \"maxClicksSet\": true}",
- "{\"search\": \"example\"}",
- "{\"createdAfter\": \"2024-01-01\", \"createdBefore\": \"2024-12-31\", \"status\": \"ACTIVE\"}"
- ],
- "title": "Filter"
- },
- "description": "JSON string containing filter criteria for URLs. Format: `{\"field\": value}`\n\n**Available filter fields:**\n\n- **status** \u2014 Filter by URL status (`\"ACTIVE\"` or `\"INACTIVE\"`)\n- **createdAfter** \u2014 Filter URLs created after this date (ISO 8601 datetime or Unix timestamp)\n- **createdBefore** \u2014 Filter URLs created before this date (ISO 8601 datetime or Unix timestamp)\n- **passwordSet** \u2014 Filter by password protection (boolean: `true`/`false`)\n- **maxClicksSet** \u2014 Filter by click limit presence (boolean: `true`/`false`)\n- **search** \u2014 Search in alias or long_url (case-insensitive string)\n\n**Value formats:**\n\n- **status**: String \u2014 `\"ACTIVE\"` or `\"INACTIVE\"` (case-sensitive)\n- **createdAfter / createdBefore**: ISO 8601 datetime string (e.g., `\"2024-01-01T00:00:00Z\"`) or Unix timestamp (e.g., `1704067200`)\n- **passwordSet / maxClicksSet**: Boolean \u2014 `true` or `false`\n- **search**: String \u2014 case-insensitive search term\n\n**Examples:**\n\n- `{\"status\": \"ACTIVE\"}` \u2014 Only active URLs\n- `{\"passwordSet\": true}` \u2014 Only password-protected URLs\n- `{\"createdAfter\": \"2024-01-01T00:00:00Z\"}` \u2014 URLs created after Jan 1, 2024\n- `{\"status\": \"ACTIVE\", \"maxClicksSet\": true}` \u2014 Active URLs with click limits\n- `{\"search\": \"example\"}` \u2014 URLs containing \"example\" in alias or long_url\n- `{\"createdAfter\": \"2024-01-01\", \"createdBefore\": \"2024-12-31\", \"status\": \"ACTIVE\"}` \u2014 Active URLs from 2024"
- },
- {
- "name": "filterBy",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "Alias for filter parameter.",
- "title": "Filterby"
- },
- "description": "Alias for filter parameter."
- }
- ],
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/UrlListResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- }
- }
- },
- "/api/v1/urls/{url_id}": {
- "patch": {
- "tags": [
- "Link Management"
- ],
- "summary": "Update URL",
- "description": "Update an existing URL's properties.\n\nPartially update a shortened URL. Only provided fields are modified; omitted\nfields remain unchanged. Pass `null` to remove optional settings like\n`password`, `max_clicks`, or `expire_after`.\n\n**Authentication**: Required \u2014 you must own the URL.\n\n**API Key Scope**: `urls:manage` or `admin:all`\n\n**Rate Limits**: 120/min, 2,000/day\n\n**Updatable Fields**: `long_url`, `alias`, `password`, `block_bots`,\n`max_clicks`, `expire_after`, `private_stats`, `status`\n\n**Notes**:\n- Setting `max_clicks` to `0` or `null` removes the click limit\n- Changing the `alias` checks availability and may fail with 409 Conflict\n- The `url_id` is the MongoDB ObjectId, not the alias",
- "operationId": "updateUrl",
- "parameters": [
- {
- "name": "url_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "description": "Unique identifier of the URL",
- "title": "Url Id"
- },
- "description": "Unique identifier of the URL"
- }
- ],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/UpdateUrlRequest"
- }
- }
- }
- },
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/UpdateUrlResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "409": {
- "description": "Conflict \u2014 resource already exists",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookDeliveryResponse"
}
- },
- "delete": {
- "tags": [
- "Link Management"
- ],
- "summary": "Delete URL",
- "description": "Delete a URL permanently.\n\n**This action is IRREVERSIBLE.** The URL, its alias, and all associated\nclick analytics data will be permanently deleted. The alias may be reclaimed\nby another user afterward.\n\n**Authentication**: Required \u2014 you must own the URL.\n\n**API Key Scope**: `urls:manage` or `admin:all`\n\n**Rate Limits**: 60/min, 1,000/day\n\n**Recommendation**: Consider setting the URL status to `INACTIVE` via\n`PATCH /urls/{url_id}/status` instead if you may want to restore it later.",
- "operationId": "deleteUrl",
- "parameters": [
- {
- "name": "url_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "description": "Unique identifier of the URL",
- "title": "Url Id"
- },
- "description": "Unique identifier of the URL"
- }
- ],
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/DeleteUrlResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- }
- }
- },
- "/api/v1/urls/{url_id}/status": {
- "patch": {
- "tags": [
- "Link Management"
- ],
- "summary": "Update URL Status",
- "description": "Update only the status of a URL (ACTIVE / INACTIVE).\n\nToggle a URL between active and inactive without modifying other properties.\n\n**Authentication**: Required \u2014 you must own the URL.\n\n**API Key Scope**: `urls:manage` or `admin:all`\n\n**Rate Limits**: 120/min, 2,000/day\n\n**Status Values**:\n- `ACTIVE` \u2014 URL is accessible and redirects normally\n- `INACTIVE` \u2014 URL is disabled and returns an error page\n\n**Use Cases**:\n- Set `INACTIVE` to temporarily disable redirects without deleting the URL\n- Set `ACTIVE` to re-enable a previously disabled URL",
- "operationId": "updateUrlStatus",
- "parameters": [
- {
- "name": "url_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "description": "Unique identifier of the URL",
- "title": "Url Id"
- },
- "description": "Unique identifier of the URL"
- }
- ],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/UpdateUrlStatusRequest"
- }
- }
- }
- },
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/UpdateUrlResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- }
- }
- },
- "/api/v1/stats": {
- "get": {
- "tags": [
- "Statistics"
- ],
- "summary": "URL Statistics",
- "description": "Get click statistics for URLs.\n\nRetrieve aggregated click analytics with flexible grouping, filtering,\nand time-range options. Results are computed in a single database round-trip\nusing `$facet` aggregation.\n\n**Authentication**: Optional for `scope=anon` (public stats on a single URL);\nrequired for `scope=all` (all URLs owned by the user).\n\n**API Key Scope**: `stats:read`, `urls:read`, or `admin:all`\n\n**Rate Limits**:\n- Authenticated: 60/min, 5,000/day\n- Anonymous: 20/min, 1,000/day\n\n**Scopes**:\n- `scope=anon` + `short_code=` \u2014 public stats for one URL (if stats are not private)\n- `scope=all` \u2014 aggregate stats across all URLs owned by the authenticated user\n\n**Grouping Dimensions**: `time`, `browser`, `os`, `country`, `city`,\n`referrer`, `short_code`\n\n**Metrics**: `clicks`, `unique_clicks`\n\n**Filtering**: Filter by `browser`, `os`, `country`, `city`, `referrer`,\nor `short_code` using query params or a JSON `filters` object.",
- "operationId": "getStats",
- "parameters": [
- {
- "name": "scope",
- "in": "query",
- "required": true,
- "schema": {
- "enum": [
- "all",
- "anon"
- ],
- "type": "string",
- "description": "Statistics scope: `all` (authenticated only) or `anon` (public access).\n\n- `all` \u2014 aggregate stats across all URLs owned by the authenticated user. Requires authentication.\n- `anon` \u2014 public stats for a single URL. Requires `short_code` parameter. No authentication needed (unless stats are private).",
- "title": "Scope"
- },
- "description": "Statistics scope: `all` (authenticated only) or `anon` (public access).\n\n- `all` \u2014 aggregate stats across all URLs owned by the authenticated user. Requires authentication.\n- `anon` \u2014 public stats for a single URL. Requires `short_code` parameter. No authentication needed (unless stats are private)."
- },
- {
- "name": "short_code",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "URL alias to query stats for. **Required** when `scope=anon`. When `scope=all`, this is optional and filters stats to a specific URL.",
- "examples": [
- "mylink"
- ],
- "title": "Short Code"
- },
- "description": "URL alias to query stats for. **Required** when `scope=anon`. When `scope=all`, this is optional and filters stats to a specific URL."
- },
- {
- "name": "start_date",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to the URL creation date.",
- "examples": [
- "2025-01-01T00:00:00Z"
- ],
- "title": "Start Date"
- },
- "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to the URL creation date."
- },
- {
- "name": "end_date",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now.",
- "examples": [
- "2025-12-31T23:59:59Z"
- ],
- "title": "End Date"
- },
- "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now."
- },
- {
- "name": "group_by",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` \u2014 group by time buckets (day/week/month, auto-selected based on range)\n- `browser` \u2014 group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` \u2014 group by operating system (e.g., Windows, macOS, Linux)\n- `country` \u2014 group by country\n- `city` \u2014 group by city\n- `referrer` \u2014 group by referrer URL\n- `short_code` \u2014 group by URL alias (only with `scope=all`)\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser.",
- "examples": [
- "time,browser",
- "country",
- "time,country,browser"
- ],
- "title": "Group By"
- },
- "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` \u2014 group by time buckets (day/week/month, auto-selected based on range)\n- `browser` \u2014 group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` \u2014 group by operating system (e.g., Windows, macOS, Linux)\n- `country` \u2014 group by country\n- `city` \u2014 group by city\n- `referrer` \u2014 group by referrer URL\n- `short_code` \u2014 group by URL alias (only with `scope=all`)\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser."
- },
- {
- "name": "metrics",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` \u2014 total click count\n- `unique_clicks` \u2014 unique visitor count (deduplicated by IP + User-Agent)",
- "examples": [
- "clicks,unique_clicks",
- "clicks"
- ],
- "title": "Metrics"
- },
- "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` \u2014 total click count\n- `unique_clicks` \u2014 unique visitor count (deduplicated by IP + User-Agent)"
- },
- {
- "name": "timezone",
- "in": "query",
- "required": false,
- "schema": {
- "type": "string",
- "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`.",
- "examples": [
- "UTC",
- "America/New_York"
- ],
- "default": "UTC",
- "title": "Timezone"
- },
- "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`."
- },
- {
- "name": "filters",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` \u2014 Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` \u2014 Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `country` \u2014 Filter by country name (e.g., United States, Canada, Germany)\n- `city` \u2014 Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` \u2014 Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `short_code` \u2014 Filter by URL alias (e.g., mylink, promo2024) \u2014 **not allowed** with `scope=anon`\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` \u2014 Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` \u2014 US/CA clicks from Chrome\n- `{\"short_code\": [\"link1\", \"link2\"]}` \u2014 Stats for specific URLs (`scope=all` only)\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below).",
- "examples": [
- "{\"browser\":[\"Chrome\",\"Firefox\"]}",
- "{\"country\":[\"United States\",\"Canada\"],\"browser\":[\"Chrome\"]}"
- ],
- "title": "Filters"
- },
- "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` \u2014 Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` \u2014 Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `country` \u2014 Filter by country name (e.g., United States, Canada, Germany)\n- `city` \u2014 Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` \u2014 Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `short_code` \u2014 Filter by URL alias (e.g., mylink, promo2024) \u2014 **not allowed** with `scope=anon`\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` \u2014 Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` \u2014 US/CA clicks from Chrome\n- `{\"short_code\": [\"link1\", \"link2\"]}` \u2014 Stats for specific URLs (`scope=all` only)\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below)."
- },
- {
- "name": "browser",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
- "examples": [
- "Chrome,Firefox"
- ],
- "title": "Browser"
- },
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
- },
- {
- "name": "os",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
- "examples": [
- "Windows,macOS"
- ],
- "title": "Os"
- },
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
- },
- {
- "name": "country",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
- "examples": [
- "United States,Germany"
- ],
- "title": "Country"
- },
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
- },
- {
- "name": "city",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
- "examples": [
- "San Francisco,Berlin"
- ],
- "title": "City"
- },
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
- },
- {
- "name": "referrer",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
- "examples": [
- "https://google.com,https://twitter.com"
- ],
- "title": "Referrer"
- },
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
- }
- ],
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/StatsResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- },
- "security": [
- {},
- {
- "ApiKeyAuth": []
- },
- {
- "JWTAuth": []
- }
- ]
+ }
}
- },
- "/api/v1/export": {
- "get": {
- "tags": [
- "Statistics"
- ],
- "summary": "Export Statistics",
- "description": "Export URL click statistics as a downloadable file.\n\nGenerate a file export of click analytics data in the specified format.\nThe response is a binary download with appropriate `Content-Disposition` header.\n\n**Authentication**: Optional for `scope=anon` (public stats on a single URL);\nrequired for `scope=all`.\n\n**API Key Scope**: `stats:read`, `urls:read`, or `admin:all`\n\n**Rate Limits**:\n- Authenticated: 30/min, 1,000/day\n- Anonymous: 10/min, 200/day\n\n**Export Formats**:\n- `json` \u2014 single JSON file\n- `xml` \u2014 single XML file\n- `xlsx` \u2014 Excel spreadsheet with multiple sheets\n- `csv` \u2014 **ZIP archive** containing `summary.csv` plus one CSV file per metrics dimension\n\n**Note**: Export generation is resource-intensive. Lower rate limits apply\ncompared to other endpoints.",
- "operationId": "exportStats",
- "parameters": [
- {
- "name": "scope",
- "in": "query",
- "required": true,
- "schema": {
- "enum": [
- "all",
- "anon"
- ],
- "type": "string",
- "description": "Statistics scope: `all` (authenticated only) or `anon` (public access).\n\n- `all` \u2014 aggregate stats across all URLs owned by the authenticated user. Requires authentication.\n- `anon` \u2014 public stats for a single URL. Requires `short_code` parameter. No authentication needed (unless stats are private).",
- "title": "Scope"
- },
- "description": "Statistics scope: `all` (authenticated only) or `anon` (public access).\n\n- `all` \u2014 aggregate stats across all URLs owned by the authenticated user. Requires authentication.\n- `anon` \u2014 public stats for a single URL. Requires `short_code` parameter. No authentication needed (unless stats are private)."
- },
- {
- "name": "short_code",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "URL alias to query stats for. **Required** when `scope=anon`. When `scope=all`, this is optional and filters stats to a specific URL.",
- "examples": [
- "mylink"
- ],
- "title": "Short Code"
- },
- "description": "URL alias to query stats for. **Required** when `scope=anon`. When `scope=all`, this is optional and filters stats to a specific URL."
- },
- {
- "name": "start_date",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to the URL creation date.",
- "examples": [
- "2025-01-01T00:00:00Z"
- ],
- "title": "Start Date"
- },
- "description": "Start of time range. Accepts ISO 8601 datetime string (e.g., `2025-01-01T00:00:00Z`) or Unix timestamp in seconds (e.g., `1735689600`). If omitted, defaults to the URL creation date."
- },
- {
- "name": "end_date",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now.",
- "examples": [
- "2025-12-31T23:59:59Z"
- ],
- "title": "End Date"
- },
- "description": "End of time range. Accepts ISO 8601 datetime string (e.g., `2025-12-31T23:59:59Z`) or Unix timestamp in seconds (e.g., `1767225599`). If omitted, defaults to now."
- },
- {
- "name": "group_by",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` \u2014 group by time buckets (day/week/month, auto-selected based on range)\n- `browser` \u2014 group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` \u2014 group by operating system (e.g., Windows, macOS, Linux)\n- `country` \u2014 group by country\n- `city` \u2014 group by city\n- `referrer` \u2014 group by referrer URL\n- `short_code` \u2014 group by URL alias (only with `scope=all`)\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser.",
- "examples": [
- "time,browser",
- "country",
- "time,country,browser"
- ],
- "title": "Group By"
- },
- "description": "Comma-separated grouping dimensions for the statistics breakdown. Defaults to `time` if omitted.\n\n**Available dimensions:**\n\n- `time` \u2014 group by time buckets (day/week/month, auto-selected based on range)\n- `browser` \u2014 group by browser name (e.g., Chrome, Firefox, Safari)\n- `os` \u2014 group by operating system (e.g., Windows, macOS, Linux)\n- `country` \u2014 group by country\n- `city` \u2014 group by city\n- `referrer` \u2014 group by referrer URL\n- `short_code` \u2014 group by URL alias (only with `scope=all`)\n\nMultiple dimensions can be combined: `time,browser` returns time series broken down by browser."
- },
- {
- "name": "metrics",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` \u2014 total click count\n- `unique_clicks` \u2014 unique visitor count (deduplicated by IP + User-Agent)",
- "examples": [
- "clicks,unique_clicks",
- "clicks"
- ],
- "title": "Metrics"
- },
- "description": "Comma-separated metrics to include. Defaults to `clicks,unique_clicks` if omitted.\n\n**Available metrics:**\n\n- `clicks` \u2014 total click count\n- `unique_clicks` \u2014 unique visitor count (deduplicated by IP + User-Agent)"
- },
- {
- "name": "timezone",
- "in": "query",
- "required": false,
- "schema": {
- "type": "string",
- "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`.",
- "examples": [
- "UTC",
- "America/New_York"
- ],
- "default": "UTC",
- "title": "Timezone"
- },
- "description": "IANA timezone name for time-based grouping and output formatting (e.g., `UTC`, `America/New_York`, `Asia/Kolkata`). Defaults to `UTC`."
- },
- {
- "name": "filters",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` \u2014 Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` \u2014 Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `country` \u2014 Filter by country name (e.g., United States, Canada, Germany)\n- `city` \u2014 Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` \u2014 Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `short_code` \u2014 Filter by URL alias (e.g., mylink, promo2024) \u2014 **not allowed** with `scope=anon`\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` \u2014 Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` \u2014 US/CA clicks from Chrome\n- `{\"short_code\": [\"link1\", \"link2\"]}` \u2014 Stats for specific URLs (`scope=all` only)\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below).",
- "examples": [
- "{\"browser\":[\"Chrome\",\"Firefox\"]}",
- "{\"country\":[\"United States\",\"Canada\"],\"browser\":[\"Chrome\"]}"
- ],
- "title": "Filters"
- },
- "description": "**Method 1: JSON Filters Object**\n\nJSON string containing dimension filters. Format: `{\"dimension\": [\"value1\", \"value2\"]}`\n\n**Available filter dimensions:**\n\n- `browser` \u2014 Filter by browser name (e.g., Chrome, Firefox, Safari, Edge)\n- `os` \u2014 Filter by operating system (e.g., Windows, macOS, Linux, iOS, Android)\n- `country` \u2014 Filter by country name (e.g., United States, Canada, Germany)\n- `city` \u2014 Filter by city name (e.g., New York, London, Mumbai)\n- `referrer` \u2014 Filter by referrer URL (e.g., https://google.com, https://twitter.com)\n- `short_code` \u2014 Filter by URL alias (e.g., mylink, promo2024) \u2014 **not allowed** with `scope=anon`\n\n**Value format:** Array of strings for each dimension.\n\n**Important:** Filter values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Examples:**\n\n- `{\"browser\": [\"Chrome\", \"Firefox\"]}` \u2014 Chrome OR Firefox clicks\n- `{\"country\": [\"United States\", \"Canada\"], \"browser\": [\"Chrome\"]}` \u2014 US/CA clicks from Chrome\n- `{\"short_code\": [\"link1\", \"link2\"]}` \u2014 Stats for specific URLs (`scope=all` only)\n\n**Alternative:** You can also pass filters as individual query parameters (see `browser`, `os`, `country`, `city`, `referrer` parameters below)."
- },
- {
- "name": "browser",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
- "examples": [
- "Chrome,Firefox"
- ],
- "title": "Browser"
- },
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated browser names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Chrome, Firefox, Safari, Edge, Opera, Samsung Internet.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
- },
- {
- "name": "os",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
- "examples": [
- "Windows,macOS"
- ],
- "title": "Os"
- },
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated operating system names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Common values include: Windows, macOS, Linux, iOS, Android, Chrome OS.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
- },
- {
- "name": "country",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
- "examples": [
- "United States,Germany"
- ],
- "title": "Country"
- },
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated country names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use full country names as stored in the database (e.g., United States, Canada, United Kingdom, India, Germany, France, Japan).\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
- },
- {
- "name": "city",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
- "examples": [
- "San Francisco,Berlin"
- ],
- "title": "City"
- },
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated city names. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Use exact capitalization as stored in the database.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
- },
- {
- "name": "referrer",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined.",
- "examples": [
- "https://google.com,https://twitter.com"
- ],
- "title": "Referrer"
- },
- "description": "**Method 2: Individual Filter Parameter**\n\nComma-separated referrer URLs. Alternative to using the `filters` JSON parameter.\n\n**Important:** Values are case-sensitive. Include the full URL including protocol.\n\n**Note:** Both `filters` JSON and individual parameters can be combined."
- },
- {
- "name": "format",
- "in": "query",
- "required": true,
- "schema": {
- "enum": [
- "csv",
- "xlsx",
- "json",
- "xml"
- ],
- "type": "string",
- "description": "Export file format.",
- "title": "Format"
- },
- "description": "Export file format."
- }
- ],
- "responses": {
- "200": {
- "description": "Export file download",
- "content": {
- "application/json": {
- "schema": {
- "type": "string",
- "format": "binary"
- }
- },
- "application/xml": {
- "schema": {
- "type": "string",
- "format": "binary"
- }
- },
- "application/zip": {
- "schema": {
- "type": "string",
- "format": "binary"
- },
- "x-description": "CSV export \u2014 ZIP archive containing summary.csv plus one file per dimension"
- },
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
- "schema": {
- "type": "string",
- "format": "binary"
- },
- "x-description": "XLSX export \u2014 Excel workbook with multiple sheets"
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "500": {
- "description": "Internal server error \u2014 export generation failed",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- },
- "security": [
- {},
- {
- "ApiKeyAuth": []
- },
- {
- "JWTAuth": []
- }
- ]
+ },
+ "400": {
+ "description": "Bad Request - invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
}
- },
- "/api/v1/keys": {
- "get": {
- "tags": [
- "API Keys"
- ],
- "summary": "List API Keys",
- "description": "List all API keys for the authenticated user.\n\nReturns metadata for all API keys (both active and revoked) belonging to the\nauthenticated user. The full token value is **never** returned in this\nendpoint for security reasons \u2014 only the `token_prefix` is shown.\n\n**Authentication**: Required \u2014 JWT Bearer or API key with appropriate scope.\n\n**Rate Limits**: 60/min",
- "operationId": "listApiKeys",
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ApiKeysListResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid credentials",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
}
- },
- "post": {
- "tags": [
- "API Keys"
- ],
- "summary": "Create API Key",
- "description": "Create a new API key for programmatic access.\n\nGenerate a new API key with the specified name and scopes. The full token\n(prefixed with `spoo_`) is returned **only in this response** and cannot be\nretrieved again.\n\n**Authentication**: Required \u2014 JWT Bearer only (API keys cannot create other\nAPI keys). Email must be verified.\n\n**Rate Limits**: 5/hour\n\n**Available Scopes**: `shorten:create`, `urls:manage`, `urls:read`,\n`stats:read`, `admin:all`\n\n**Notes**:\n- Store the returned `token` securely \u2014 it will not be shown again\n- Set `expires_at` to limit the key's lifetime (ISO 8601 or Unix epoch)\n- Omit `expires_at` for a non-expiring key",
- "operationId": "createApiKey",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/CreateApiKeyRequest"
- }
- }
- },
- "required": true
- },
- "responses": {
- "201": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ApiKeyCreatedResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "409": {
- "description": "Conflict \u2014 resource already exists",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- }
- }
- },
- "/api/v1/keys/{key_id}": {
- "delete": {
- "tags": [
- "API Keys"
- ],
- "summary": "Delete/Revoke API Key",
- "description": "Delete or revoke an API key.\n\nRemove an API key either permanently (hard delete) or by marking it as\nrevoked (soft delete). Revoked keys stop working immediately but remain\nvisible in the key list for audit purposes.\n\n**Authentication**: Required \u2014 JWT Bearer or API key.\n\n**Rate Limits**: 30/min\n\n**Modes**:\n- `?revoke=false` (default) \u2014 **permanently deletes** the key record\n- `?revoke=true` \u2014 marks the key as revoked but preserves the record;\n the key appears with `revoked: true` in the list endpoint",
- "operationId": "deleteApiKey",
- "parameters": [
- {
- "name": "key_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "title": "Key Id"
- }
- },
- {
- "name": "revoke",
- "in": "query",
- "required": false,
- "schema": {
- "type": "boolean",
- "default": false,
- "title": "Revoke"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ApiKeyActionResponse"
- }
- }
- }
- },
- "400": {
- "description": "Bad Request \u2014 invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "401": {
- "description": "Unauthorized \u2014 missing or invalid credentials",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "403": {
- "description": "Forbidden \u2014 insufficient permissions or scope",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "404": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "429": {
- "description": "Rate limit exceeded",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - insufficient permissions or scope",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate limit exceeded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - resource already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
}
+ }
}
+ }
}
- },
- "components": {
- "schemas": {
- "ApiKeyActionResponse": {
- "properties": {
- "success": {
- "type": "boolean",
- "title": "Success",
- "description": "Whether the action completed successfully"
- },
- "action": {
- "type": "string",
- "title": "Action",
- "description": "Action that was performed",
- "examples": [
- "deleted"
- ]
- }
- },
- "type": "object",
- "required": [
- "success",
- "action"
- ],
- "title": "ApiKeyActionResponse",
- "description": "Response body for DELETE /api/v1/keys/{key_id}."
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "AliasCheckResponse": {
+ "properties": {
+ "available": {
+ "type": "boolean",
+ "title": "Available",
+ "description": "Whether the alias is free to use."
+ },
+ "reason": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Reason",
+ "description": "When unavailable: 'length', 'format', 'reserved', 'taken', or 'emoji_policy' (emoji alias contains sequences outside the accepted set - ZWJ, flags, keycaps, or too-new emoji).",
+ "examples": [
+ "taken"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "available"
+ ],
+ "title": "AliasCheckResponse",
+ "description": "Response body for GET /api/v1/shorten/check-alias.\n\n``available`` is true only when the alias passes format/length validation\nAND is not already taken. When false, ``reason`` explains why so the UI\ncan render a precise, non-generic message."
+ },
+ "ApiKeyActionResponse": {
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "title": "Success",
+ "description": "Whether the action completed successfully"
+ },
+ "action": {
+ "type": "string",
+ "title": "Action",
+ "description": "Action that was performed",
+ "examples": [
+ "deleted"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "success",
+ "action"
+ ],
+ "title": "ApiKeyActionResponse",
+ "description": "Response body for DELETE /api/v1/keys/{key_id}."
+ },
+ "ApiKeyCreatedResponse": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "API key ID",
+ "examples": [
+ "507f1f77bcf86cd799439011"
+ ]
+ },
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "Human-readable key name",
+ "examples": [
+ "My Production Key"
+ ]
+ },
+ "description": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Description",
+ "description": "Optional description",
+ "examples": [
+ "Used by the mobile app"
+ ]
+ },
+ "scopes": {
+ "items": {
+ "type": "string"
},
- "ApiKeyCreatedResponse": {
- "properties": {
- "id": {
- "type": "string",
- "title": "Id",
- "description": "API key ID",
- "examples": [
- "507f1f77bcf86cd799439011"
- ]
- },
- "name": {
- "type": "string",
- "title": "Name",
- "description": "Human-readable key name",
- "examples": [
- "My Production Key"
- ]
- },
- "description": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Description",
- "description": "Optional description",
- "examples": [
- "Used by the mobile app"
- ]
- },
- "scopes": {
- "items": {
- "type": "string"
- },
- "type": "array",
- "title": "Scopes",
- "description": "Permission scopes granted to this key",
- "examples": [
- [
- "shorten:create",
- "stats:read"
- ]
- ]
- },
- "created_at": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Created At",
- "description": "Creation time as Unix timestamp",
- "examples": [
- 1704067200
- ]
- },
- "expires_at": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Expires At",
- "description": "Expiration time as Unix timestamp, or null if no expiration",
- "examples": [
- 1735689600
- ]
- },
- "revoked": {
- "type": "boolean",
- "title": "Revoked",
- "description": "Whether the key has been revoked"
- },
- "token_prefix": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Token Prefix",
- "description": "First characters of the token for identification",
- "examples": [
- "spoo_abc1"
- ]
- },
- "token": {
- "type": "string",
- "title": "Token",
- "description": "Full API key token (only returned once at creation time)",
- "examples": [
- "spoo_abc123def456ghi789"
- ]
- }
- },
- "type": "object",
- "required": [
- "id",
- "name",
- "scopes",
- "revoked",
- "token"
- ],
- "title": "ApiKeyCreatedResponse",
- "description": "Response for POST /api/v1/keys (201).\n\nExtends ApiKeyResponse by adding the full ``token``. This is the ONLY time\nthe token is returned \u2014 it is hashed before storage."
+ "type": "array",
+ "title": "Scopes",
+ "description": "Permission scopes granted to this key",
+ "examples": [
+ [
+ "shorten:create",
+ "stats:read"
+ ]
+ ]
+ },
+ "created_at": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Created At",
+ "description": "Creation time as Unix timestamp",
+ "examples": [
+ 1704067200
+ ]
+ },
+ "expires_at": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Expires At",
+ "description": "Expiration time as Unix timestamp, or null if no expiration",
+ "examples": [
+ 1735689600
+ ]
+ },
+ "revoked": {
+ "type": "boolean",
+ "title": "Revoked",
+ "description": "Whether the key has been revoked"
+ },
+ "token_prefix": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Token Prefix",
+ "description": "First characters of the token for identification",
+ "examples": [
+ "spoo_abc1"
+ ]
+ },
+ "last_used_at": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Used At",
+ "description": "Last time this key authenticated a request, as Unix timestamp. Null if the key has never been used. Updated at most once per hour.",
+ "examples": [
+ 1704067200
+ ]
+ },
+ "token": {
+ "type": "string",
+ "title": "Token",
+ "description": "Full API key token (only returned once at creation time)",
+ "examples": [
+ "spoo_abc123def456ghi789"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "scopes",
+ "revoked",
+ "token"
+ ],
+ "title": "ApiKeyCreatedResponse",
+ "description": "Response for POST /api/v1/keys (201).\n\nExtends ApiKeyResponse by adding the full ``token``. This is the ONLY time\nthe token is returned - it is hashed before storage."
+ },
+ "ApiKeyResponse": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "API key ID",
+ "examples": [
+ "507f1f77bcf86cd799439011"
+ ]
+ },
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "Human-readable key name",
+ "examples": [
+ "My Production Key"
+ ]
+ },
+ "description": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Description",
+ "description": "Optional description",
+ "examples": [
+ "Used by the mobile app"
+ ]
+ },
+ "scopes": {
+ "items": {
+ "type": "string"
},
- "ApiKeyResponse": {
- "properties": {
- "id": {
- "type": "string",
- "title": "Id",
- "description": "API key ID",
- "examples": [
- "507f1f77bcf86cd799439011"
- ]
- },
- "name": {
- "type": "string",
- "title": "Name",
- "description": "Human-readable key name",
- "examples": [
- "My Production Key"
- ]
- },
- "description": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Description",
- "description": "Optional description",
- "examples": [
- "Used by the mobile app"
- ]
- },
- "scopes": {
- "items": {
- "type": "string"
- },
- "type": "array",
- "title": "Scopes",
- "description": "Permission scopes granted to this key",
- "examples": [
- [
- "shorten:create",
- "stats:read"
- ]
- ]
- },
- "created_at": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Created At",
- "description": "Creation time as Unix timestamp",
- "examples": [
- 1704067200
- ]
- },
- "expires_at": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Expires At",
- "description": "Expiration time as Unix timestamp, or null if no expiration",
- "examples": [
- 1735689600
- ]
- },
- "revoked": {
- "type": "boolean",
- "title": "Revoked",
- "description": "Whether the key has been revoked"
- },
- "token_prefix": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Token Prefix",
- "description": "First characters of the token for identification",
- "examples": [
- "spoo_abc1"
- ]
- }
- },
- "type": "object",
- "required": [
- "id",
- "name",
- "scopes",
- "revoked"
- ],
- "title": "ApiKeyResponse",
- "description": "A single API key entry as returned by the list endpoint.\n\nThe full token is never returned here \u2014 only the ``token_prefix`` for display."
+ "type": "array",
+ "title": "Scopes",
+ "description": "Permission scopes granted to this key",
+ "examples": [
+ [
+ "shorten:create",
+ "stats:read"
+ ]
+ ]
+ },
+ "created_at": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Created At",
+ "description": "Creation time as Unix timestamp",
+ "examples": [
+ 1704067200
+ ]
+ },
+ "expires_at": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Expires At",
+ "description": "Expiration time as Unix timestamp, or null if no expiration",
+ "examples": [
+ 1735689600
+ ]
+ },
+ "revoked": {
+ "type": "boolean",
+ "title": "Revoked",
+ "description": "Whether the key has been revoked"
+ },
+ "token_prefix": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Token Prefix",
+ "description": "First characters of the token for identification",
+ "examples": [
+ "spoo_abc1"
+ ]
+ },
+ "last_used_at": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Used At",
+ "description": "Last time this key authenticated a request, as Unix timestamp. Null if the key has never been used. Updated at most once per hour.",
+ "examples": [
+ 1704067200
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "scopes",
+ "revoked"
+ ],
+ "title": "ApiKeyResponse",
+ "description": "A single API key entry as returned by the list endpoint.\n\nThe full token is never returned here - only the ``token_prefix`` for display."
+ },
+ "ApiKeysListResponse": {
+ "properties": {
+ "keys": {
+ "items": {
+ "$ref": "#/components/schemas/ApiKeyResponse"
},
- "ApiKeysListResponse": {
- "properties": {
- "keys": {
- "items": {
- "$ref": "#/components/schemas/ApiKeyResponse"
- },
- "type": "array",
- "title": "Keys",
- "description": "List of API keys for the authenticated user"
- }
- },
- "type": "object",
- "required": [
- "keys"
- ],
- "title": "ApiKeysListResponse",
- "description": "Response body for GET /api/v1/keys."
+ "type": "array",
+ "title": "Keys",
+ "description": "List of API keys for the authenticated user"
+ }
+ },
+ "type": "object",
+ "required": [
+ "keys"
+ ],
+ "title": "ApiKeysListResponse",
+ "description": "Response body for GET /api/v1/keys."
+ },
+ "AppGrantResponse": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "Grant ID (accepted as `grant_id` by POST /auth/device/revoke)",
+ "examples": [
+ "665f1c2ab7e94d0c8a1f2b3c"
+ ]
+ },
+ "app": {
+ "type": "string",
+ "title": "App",
+ "description": "App registry key (config/apps.yaml). Shares a namespace with the frontend catalogue slugs and is the `app_id` handle for POST /auth/device/revoke.",
+ "examples": [
+ "spoo-cli"
+ ]
+ },
+ "app_name": {
+ "type": "string",
+ "title": "App Name",
+ "description": "Display name from the registry (falls back to `app`)",
+ "examples": [
+ "Spoo CLI"
+ ]
+ },
+ "icon": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Icon",
+ "description": "Registry icon filename, or null when the entry is gone",
+ "examples": [
+ "spoo-cli.svg"
+ ]
+ },
+ "scopes": {
+ "items": {
+ "type": "string"
},
- "AuthProviderInfo": {
- "properties": {
- "provider": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Provider",
- "description": "OAuth provider name",
- "examples": [
- "google"
- ]
- },
- "email": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Email",
- "description": "Email address from the OAuth provider",
- "examples": [
- "user@gmail.com"
- ]
- },
- "linked_at": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Linked At",
- "description": "ISO 8601 timestamp when the provider was linked",
- "examples": [
- "2025-01-15T10:30:00+00:00"
- ]
- }
- },
- "type": "object",
- "title": "AuthProviderInfo",
- "description": "Minimal OAuth provider entry returned inside UserProfileResponse."
+ "type": "array",
+ "title": "Scopes",
+ "description": "Effective scope slugs the grant confers. Empty list means a legacy unrestricted grant (consented before scoped grants existed) - full account access, not zero access.",
+ "examples": [
+ [
+ "shorten:create",
+ "urls:read"
+ ]
+ ]
+ },
+ "permissions": {
+ "items": {
+ "type": "string"
},
- "ComputedMetrics": {
- "properties": {
- "unique_click_rate": {
- "type": "number",
- "title": "Unique Click Rate"
- },
- "repeat_click_rate": {
- "type": "number",
- "title": "Repeat Click Rate"
- },
- "average_clicks_per_visitor": {
- "type": "number",
- "title": "Average Clicks Per Visitor"
- }
- },
- "type": "object",
- "required": [
- "unique_click_rate",
- "repeat_click_rate",
- "average_clicks_per_visitor"
- ],
- "title": "ComputedMetrics",
- "description": "Optional computed metrics added by format_stats_response_with_metadata."
+ "type": "array",
+ "title": "Permissions",
+ "description": "Human-readable consent sentences derived from `scopes` (full-access sentence for legacy unrestricted grants)",
+ "examples": [
+ [
+ "Create short links",
+ "List and read links"
+ ]
+ ]
+ },
+ "granted_at": {
+ "type": "string",
+ "title": "Granted At",
+ "description": "When access was granted (ISO 8601 UTC)"
+ },
+ "last_used_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Used At",
+ "description": "Last token exchange/refresh by this app, null if never used"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "app",
+ "app_name",
+ "scopes",
+ "permissions",
+ "granted_at"
+ ],
+ "title": "AppGrantResponse",
+ "description": "A single connected app as returned by the list endpoint."
+ },
+ "AppGrantsListResponse": {
+ "properties": {
+ "items": {
+ "items": {
+ "$ref": "#/components/schemas/AppGrantResponse"
},
- "CreateApiKeyRequest": {
- "properties": {
- "name": {
- "type": "string",
- "title": "Name",
- "description": "Human-readable key name",
- "examples": [
- "My Production Key"
- ]
- },
- "description": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Description",
- "description": "Optional description of what this key is used for",
- "examples": [
- "Used by the mobile app for URL shortening"
- ]
- },
- "scopes": {
- "items": {
- "type": "string"
- },
- "type": "array",
- "title": "Scopes",
- "description": "Permission scopes for the key",
- "examples": [
- [
- "shorten:create",
- "stats:read"
- ]
- ]
- },
- "expires_at": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Expires At",
- "description": "Expiration time. ISO 8601 string (e.g. `2026-01-01T00:00:00Z`) or Unix epoch seconds (e.g. `1735689599`). Omit for non-expiring key.",
- "examples": [
- "2026-01-01T00:00:00Z",
- 1735689599
- ]
- }
- },
- "type": "object",
- "required": [
- "name",
- "scopes"
- ],
- "title": "CreateApiKeyRequest",
- "description": "Request body for POST /api/v1/keys."
+ "type": "array",
+ "title": "Items",
+ "description": "Active grants, newest granted_at first"
+ }
+ },
+ "type": "object",
+ "required": [
+ "items"
+ ],
+ "title": "AppGrantsListResponse",
+ "description": "Response body for GET /api/v1/apps."
+ },
+ "AuthProviderInfo": {
+ "properties": {
+ "provider": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/OAuthProvider"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "OAuth provider name",
+ "examples": [
+ "google"
+ ]
+ },
+ "email": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Email",
+ "description": "Email address from the OAuth provider",
+ "examples": [
+ "user@gmail.com"
+ ]
+ },
+ "linked_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Linked At",
+ "description": "When the provider was linked",
+ "examples": [
+ "2025-01-15T10:30:00+00:00"
+ ]
+ }
+ },
+ "type": "object",
+ "title": "AuthProviderInfo",
+ "description": "Minimal OAuth provider entry returned inside UserProfileResponse."
+ },
+ "AvailablePicture": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "Unique identifier (provider_providerUserId)"
+ },
+ "url": {
+ "type": "string",
+ "title": "Url",
+ "description": "Picture URL"
+ },
+ "source": {
+ "$ref": "#/components/schemas/OAuthProvider",
+ "description": "OAuth provider source"
+ },
+ "is_current": {
+ "type": "boolean",
+ "title": "Is Current",
+ "description": "Whether this is the active picture"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "url",
+ "source",
+ "is_current"
+ ],
+ "title": "AvailablePicture",
+ "description": "A profile picture option from a linked OAuth provider."
+ },
+ "AvailablePicturesResponse": {
+ "properties": {
+ "pictures": {
+ "items": {
+ "$ref": "#/components/schemas/AvailablePicture"
},
- "CreateUrlRequest": {
- "properties": {
- "long_url": {
- "type": "string",
- "title": "Long Url",
- "description": "The destination URL to shorten. Must be a valid http:// or https:// URL.",
- "examples": [
- "https://example.com/very/long/url/path"
- ]
- },
- "alias": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Alias",
- "description": "Custom short code. Alphanumeric, hyphens, underscores. 3-16 chars. Auto-generated if omitted.",
- "examples": [
- "mylink"
- ]
- },
- "password": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Password",
- "description": "Password to protect the URL. Min 8 chars, must contain letter + number + special char.",
- "examples": [
- "secure@123"
- ]
- },
- "block_bots": {
- "anyOf": [
- {
- "type": "boolean"
- },
- {
- "type": "null"
- }
- ],
- "title": "Block Bots",
- "description": "Block known bot user agents from accessing the URL."
- },
- "max_clicks": {
- "anyOf": [
- {
- "type": "integer",
- "exclusiveMinimum": 0.0
- },
- {
- "type": "null"
- }
- ],
- "title": "Max Clicks",
- "description": "Maximum clicks before the URL expires. Must be positive.",
- "examples": [
- 100
- ]
- },
- "expire_after": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Expire After",
- "description": "Expiration time. ISO 8601 string (e.g. `2025-12-31T23:59:59Z`) or Unix epoch seconds (e.g. `1735689599`).",
- "examples": [
- "2025-12-31T23:59:59Z",
- 1735689599
- ]
- },
- "private_stats": {
- "anyOf": [
- {
- "type": "boolean"
- },
- {
- "type": "null"
- }
- ],
- "title": "Private Stats",
- "description": "Make statistics private (only owner can view). Requires authentication."
- }
- },
- "type": "object",
- "required": [
- "long_url"
- ],
- "title": "CreateUrlRequest",
- "description": "Request body for creating a new shortened URL.\n\nAccepts ``url`` as an alias for ``long_url`` \u2014 the existing API supports both."
+ "type": "array",
+ "title": "Pictures"
+ }
+ },
+ "type": "object",
+ "required": [
+ "pictures"
+ ],
+ "title": "AvailablePicturesResponse"
+ },
+ "BulkDeleteUrlsRequest": {
+ "properties": {
+ "ids": {
+ "items": {
+ "type": "string"
},
- "DeleteUrlResponse": {
- "properties": {
- "message": {
- "type": "string",
- "title": "Message",
- "description": "Confirmation message.",
- "examples": [
- "URL deleted"
- ]
- },
- "id": {
- "type": "string",
- "title": "Id",
- "description": "ID of the deleted URL.",
- "examples": [
- "507f1f77bcf86cd799439011"
- ]
- }
- },
- "type": "object",
- "required": [
- "message",
- "id"
- ],
- "title": "DeleteUrlResponse",
- "description": "Response body for DELETE /api/v1/urls/{url_id}."
+ "type": "array",
+ "maxItems": 100,
+ "minItems": 1,
+ "title": "Ids",
+ "description": "URL ids (MongoDB ObjectIds, as returned by the list endpoint). 1 to 100 per request; duplicates are deduplicated server-side (first occurrence wins). One malformed id rejects the whole request - nothing is attempted.",
+ "examples": [
+ [
+ "665f0c2f9e7a4b1d2c3d4e5f",
+ "665f0c2f9e7a4b1d2c3d4e60"
+ ]
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "ids"
+ ],
+ "title": "BulkDeleteUrlsRequest",
+ "description": "Request body for bulk delete - ids only, no parameters."
+ },
+ "BulkDeleteUrlsResponse": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "Confirmation message.",
+ "examples": [
+ "deleted 42 URLs on links.acme.com"
+ ]
+ },
+ "count": {
+ "type": "integer",
+ "title": "Count",
+ "description": "Number of URLs deleted.",
+ "examples": [
+ 42
+ ]
+ },
+ "domain": {
+ "type": "string",
+ "title": "Domain",
+ "description": "Domain whose URLs were deleted.",
+ "examples": [
+ "links.acme.com"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "message",
+ "count",
+ "domain"
+ ],
+ "title": "BulkDeleteUrlsResponse",
+ "description": "Response body for DELETE /api/v1/urls?domain= (bulk delete)."
+ },
+ "BulkMoveDomainRequest": {
+ "properties": {
+ "ids": {
+ "items": {
+ "type": "string"
},
- "ErrorResponse": {
- "properties": {
- "error": {
- "type": "string",
- "title": "Error"
- },
- "error_code": {
- "type": "string",
- "title": "Error Code"
- },
- "field": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Field"
- },
- "details": {
- "anyOf": [
- {},
- {
- "type": "null"
- }
- ],
- "title": "Details"
- }
- },
- "type": "object",
- "required": [
- "error",
- "error_code"
- ],
- "title": "ErrorResponse",
- "description": "Standard error JSON body produced by the AppError exception handler."
+ "type": "array",
+ "maxItems": 100,
+ "minItems": 1,
+ "title": "Ids",
+ "description": "URL ids (MongoDB ObjectIds, as returned by the list endpoint). 1 to 100 per request; duplicates are deduplicated server-side (first occurrence wins). One malformed id rejects the whole request - nothing is attempted.",
+ "examples": [
+ [
+ "665f0c2f9e7a4b1d2c3d4e5f",
+ "665f0c2f9e7a4b1d2c3d4e60"
+ ]
+ ]
+ },
+ "domain": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 253
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Domain",
+ "description": "Target domain for every id - a custom domain you own (must be ACTIVE), or `null` to move back to the system default. One target for the whole batch.",
+ "examples": [
+ "links.acme.com"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "ids",
+ "domain"
+ ],
+ "title": "BulkMoveDomainRequest",
+ "description": "Request body for bulk domain move."
+ },
+ "BulkOperationSummary": {
+ "properties": {
+ "total": {
+ "type": "integer",
+ "title": "Total",
+ "description": "Unique ids in the request (after dedupe)."
+ },
+ "succeeded": {
+ "type": "integer",
+ "title": "Succeeded",
+ "description": "Rows with ok=true."
+ },
+ "failed": {
+ "type": "integer",
+ "title": "Failed",
+ "description": "Rows with ok=false."
+ }
+ },
+ "type": "object",
+ "required": [
+ "total",
+ "succeeded",
+ "failed"
+ ],
+ "title": "BulkOperationSummary",
+ "description": "Counts derived from the result rows."
+ },
+ "BulkUpdateExpiryRequest": {
+ "properties": {
+ "ids": {
+ "items": {
+ "type": "string"
},
- "HTTPValidationError": {
- "properties": {
- "detail": {
- "items": {
- "$ref": "#/components/schemas/ValidationError"
- },
- "type": "array",
- "title": "Detail"
- }
- },
- "type": "object",
- "title": "HTTPValidationError"
+ "type": "array",
+ "maxItems": 100,
+ "minItems": 1,
+ "title": "Ids",
+ "description": "URL ids (MongoDB ObjectIds, as returned by the list endpoint). 1 to 100 per request; duplicates are deduplicated server-side (first occurrence wins). One malformed id rejects the whole request - nothing is attempted.",
+ "examples": [
+ [
+ "665f0c2f9e7a4b1d2c3d4e5f",
+ "665f0c2f9e7a4b1d2c3d4e60"
+ ]
+ ]
+ },
+ "expire_after": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date-time"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Expire After",
+ "description": "Expiration applied to every id - ISO 8601 or epoch seconds, must be in the future. Pass `null` to clear expiry. One value for the whole batch.",
+ "examples": [
+ 1767225600
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "ids",
+ "expire_after"
+ ],
+ "title": "BulkUpdateExpiryRequest",
+ "description": "Request body for bulk set/clear expiry."
+ },
+ "BulkUpdateStatusRequest": {
+ "properties": {
+ "ids": {
+ "items": {
+ "type": "string"
},
- "LoginRequest": {
- "properties": {
- "email": {
- "type": "string",
- "title": "Email",
- "description": "Account email address",
- "examples": [
- "user@example.com"
- ]
- },
- "password": {
- "type": "string",
- "title": "Password",
- "description": "Account password",
- "examples": [
- "MySecurePass123!"
- ]
- }
- },
- "type": "object",
- "required": [
- "email",
- "password"
- ],
- "title": "LoginRequest",
- "description": "Request body for POST /auth/login."
+ "type": "array",
+ "maxItems": 100,
+ "minItems": 1,
+ "title": "Ids",
+ "description": "URL ids (MongoDB ObjectIds, as returned by the list endpoint). 1 to 100 per request; duplicates are deduplicated server-side (first occurrence wins). One malformed id rejects the whole request - nothing is attempted.",
+ "examples": [
+ [
+ "665f0c2f9e7a4b1d2c3d4e5f",
+ "665f0c2f9e7a4b1d2c3d4e60"
+ ]
+ ]
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "ACTIVE",
+ "INACTIVE"
+ ],
+ "title": "Status",
+ "description": "Status applied to every id. `ACTIVE` enables redirects, `INACTIVE` disables them. `BLOCKED`/`EXPIRED` are not caller-settable, same as the single-item status endpoint.",
+ "examples": [
+ "INACTIVE"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "ids",
+ "status"
+ ],
+ "title": "BulkUpdateStatusRequest",
+ "description": "Request body for bulk activate/deactivate."
+ },
+ "BulkUrlOperationResponse": {
+ "properties": {
+ "summary": {
+ "$ref": "#/components/schemas/BulkOperationSummary"
+ },
+ "results": {
+ "items": {
+ "$ref": "#/components/schemas/BulkUrlResultRow"
},
- "LoginResponse": {
- "properties": {
- "access_token": {
- "type": "string",
- "title": "Access Token",
- "description": "JWT access token",
- "examples": [
- "eyJhbGciOiJIUzI1NiIs..."
- ]
- },
- "user": {
- "$ref": "#/components/schemas/UserProfileResponse",
- "description": "Authenticated user's profile"
- }
- },
- "type": "object",
- "required": [
- "access_token",
- "user"
- ],
- "title": "LoginResponse",
- "description": "Response body for POST /auth/login (200)."
+ "type": "array",
+ "title": "Results",
+ "description": "One row per unique requested id, in request order."
+ }
+ },
+ "type": "object",
+ "required": [
+ "summary",
+ "results"
+ ],
+ "title": "BulkUrlOperationResponse",
+ "description": "Envelope for every bulk URL operation."
+ },
+ "BulkUrlResultRow": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "The requested URL id."
+ },
+ "alias": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Alias",
+ "description": "Echoed when the id resolved to a URL you own; null otherwise."
+ },
+ "ok": {
+ "type": "boolean",
+ "title": "Ok",
+ "description": "Whether the operation succeeded for this id."
+ },
+ "error_code": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "not_found",
+ "forbidden",
+ "conflict",
+ "validation_error",
+ "internal",
+ "not_attempted"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Error Code",
+ "description": "Machine-readable failure cause; null when ok."
+ },
+ "error": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Error",
+ "description": "Human-readable failure message; null when ok."
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "ok"
+ ],
+ "title": "BulkUrlResultRow",
+ "description": "Per-item verdict.\n\n``error_code`` reuses the API's error-code slugs so clients share\none mapping with the single-item routes: ``not_found`` (no such URL\nin your account - someone else's id answers the same, deliberately),\n``forbidden`` (blocked link), ``conflict``, ``validation_error``,\nplus ``internal`` (unexpected per-item failure, logged server-side)\nand ``not_attempted`` (processing aborted before this item).\n``error`` is display-safe but not stable; ``error_code`` is the key\nto branch on."
+ },
+ "ComputedMetrics": {
+ "properties": {
+ "unique_click_rate": {
+ "type": "number",
+ "title": "Unique Click Rate"
+ },
+ "repeat_click_rate": {
+ "type": "number",
+ "title": "Repeat Click Rate"
+ },
+ "average_clicks_per_visitor": {
+ "type": "number",
+ "title": "Average Clicks Per Visitor"
+ }
+ },
+ "type": "object",
+ "required": [
+ "unique_click_rate",
+ "repeat_click_rate",
+ "average_clicks_per_visitor"
+ ],
+ "title": "ComputedMetrics",
+ "description": "Optional computed metrics added by format_stats_response_with_metadata."
+ },
+ "ContactOkResponse": {
+ "properties": {
+ "ok": {
+ "type": "boolean",
+ "title": "Ok",
+ "default": true
+ }
+ },
+ "type": "object",
+ "title": "ContactOkResponse",
+ "description": "Response body for POST /api/v1/contact."
+ },
+ "ContactRequest": {
+ "properties": {
+ "email": {
+ "type": "string",
+ "format": "email",
+ "title": "Email",
+ "description": "Sender's email address"
+ },
+ "message": {
+ "type": "string",
+ "maxLength": 4000,
+ "minLength": 1,
+ "title": "Message",
+ "description": "Message body (1-4000 characters)"
+ },
+ "captcha_token": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Captcha Token",
+ "description": "hCaptcha response token - required when captcha is configured"
+ }
+ },
+ "type": "object",
+ "required": [
+ "email",
+ "message"
+ ],
+ "title": "ContactRequest",
+ "description": "Request body for POST /api/v1/contact."
+ },
+ "CreateApiKeyRequest": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "maxLength": 255,
+ "minLength": 1,
+ "title": "Name",
+ "description": "Human-readable key name",
+ "examples": [
+ "My Production Key"
+ ]
+ },
+ "description": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 1000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Description",
+ "description": "Optional description of what this key is used for",
+ "examples": [
+ "Used by the mobile app for URL shortening"
+ ]
+ },
+ "scopes": {
+ "items": {
+ "type": "string"
},
- "LogoutResponse": {
- "properties": {
- "success": {
- "type": "boolean",
- "title": "Success",
- "description": "Always true on successful logout"
- }
- },
- "type": "object",
- "required": [
- "success"
- ],
- "title": "LogoutResponse",
- "description": "Response body for POST /auth/logout (200)."
+ "type": "array",
+ "title": "Scopes",
+ "description": "Permission scopes for the key",
+ "examples": [
+ [
+ "shorten:create",
+ "stats:read"
+ ]
+ ]
+ },
+ "expires_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Expires At",
+ "description": "Expiration time. ISO 8601 string (e.g. `2026-01-01T00:00:00Z`) or Unix epoch seconds (e.g. `1735689599`). Omit for non-expiring key.",
+ "examples": [
+ "2026-01-01T00:00:00Z",
+ 1735689599
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "name",
+ "scopes"
+ ],
+ "title": "CreateApiKeyRequest",
+ "description": "Request body for POST /api/v1/keys."
+ },
+ "CreateCustomDomainRequest": {
+ "properties": {
+ "fqdn": {
+ "type": "string",
+ "maxLength": 253,
+ "title": "Fqdn",
+ "description": "The fully-qualified domain to register (e.g. links.acme.com).",
+ "examples": [
+ "links.acme.com"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "fqdn"
+ ],
+ "title": "CreateCustomDomainRequest",
+ "description": "Body for ``POST /api/v1/custom-domains``."
+ },
+ "CreateReportsRequest": {
+ "properties": {
+ "items": {
+ "items": {
+ "$ref": "#/components/schemas/ReportItemRequest"
},
- "MeResponse": {
- "properties": {
- "user": {
- "$ref": "#/components/schemas/UserProfileResponse",
- "description": "Current authenticated user's profile"
- }
+ "type": "array",
+ "title": "Items",
+ "description": "Reported links - anonymous ≤ 25 per request, authenticated ≤ 100"
+ },
+ "reporter_email": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "email"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Reporter Email",
+ "description": "Optional contact for resolution follow-up"
+ },
+ "reporter_org": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 200
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Reporter Org",
+ "description": "Optional organisation name"
+ },
+ "captcha_token": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Captcha Token",
+ "description": "hCaptcha response token - required for anonymous submissions when captcha is configured; ignored for authenticated callers"
+ }
+ },
+ "type": "object",
+ "required": [
+ "items"
+ ],
+ "title": "CreateReportsRequest",
+ "description": "Request body for POST /api/v1/reports.\n\n``items`` size is validated in the service (anonymous ≤ 25,\nauthenticated ≤ 100; empty → 400) because the cap depends on the\ncaller's auth state."
+ },
+ "CreateUrlRequest": {
+ "properties": {
+ "long_url": {
+ "type": "string",
+ "maxLength": 8192,
+ "title": "Long Url",
+ "description": "The destination URL to shorten. Must be a valid http:// or https:// URL.",
+ "examples": [
+ "https://example.com/very/long/url/path"
+ ]
+ },
+ "alias": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 64,
+ "minLength": 1
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Alias",
+ "description": "Custom short code. Either alphanumeric (a-z, A-Z, 0-9, `_`, `-`; 3-16 chars) or emoji-only (1-15 fully-qualified emoji - no ZWJ sequences, flags, or keycaps). Auto-generated if omitted.",
+ "examples": [
+ "mylink",
+ "🚀🔥"
+ ]
+ },
+ "alias_type": {
+ "type": "string",
+ "enum": [
+ "alphanumeric",
+ "emoji"
+ ],
+ "title": "Alias Type",
+ "description": "Alias style to auto-generate when `alias` is omitted. Ignored when `alias` is provided.",
+ "default": "alphanumeric"
+ },
+ "password": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 128,
+ "minLength": 8
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Password",
+ "description": "Password to protect the URL (8-128 characters).",
+ "examples": [
+ "secure@123"
+ ]
+ },
+ "block_bots": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Block Bots",
+ "description": "Block known bot user agents from accessing the URL."
+ },
+ "max_clicks": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "exclusiveMinimum": 0.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Max Clicks",
+ "description": "Maximum clicks before the URL expires. Must be positive.",
+ "examples": [
+ 100
+ ]
+ },
+ "expire_after": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date-time"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Expire After",
+ "description": "Expiration time. ISO 8601 string (e.g. `2025-12-31T23:59:59Z`) or Unix epoch seconds (e.g. `1735689599`).",
+ "examples": [
+ "2025-12-31T23:59:59Z",
+ 1735689599
+ ]
+ },
+ "private_stats": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Private Stats",
+ "description": "Make statistics private (only owner can view). Requires authentication."
+ },
+ "domain": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 253
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Domain",
+ "description": "Custom domain fqdn to scope the short link under (e.g. `links.acme.com`). Requires authentication and ownership of an ACTIVE custom domain. Omit for the default spoo.me namespace.",
+ "examples": [
+ "links.acme.com"
+ ]
+ },
+ "geo_rules": {
+ "anyOf": [
+ {
+ "additionalProperties": {
+ "type": "string"
},
- "type": "object",
- "required": [
- "user"
- ],
- "title": "MeResponse",
- "description": "Response body for GET /auth/me (200)."
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Geo Rules",
+ "description": "Per-country destination overrides: ISO 3166-1 alpha-2 country code → destination URL (at most 50 entries by default). Visitors from a listed country are redirected to that URL; everyone else gets the default destination (`url`). Requires authentication.",
+ "examples": [
+ {
+ "IN": "https://example.in/",
+ "US": "https://example.com/us"
+ }
+ ]
+ },
+ "meta_tags": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MetaTagsRequest"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Custom social preview served to link-preview crawlers (WhatsApp, Discord, Slack, iMessage, …). The object replaces the whole setting; on PATCH pass null to remove. Requires a verified account with the feature enabled. Note: platforms cache previews for ~7-30 days - edits propagate slowly (the Facebook Sharing Debugger, LinkedIn Post Inspector, and Telegram's @WebpageBot force a refresh)."
+ }
+ },
+ "type": "object",
+ "required": [
+ "long_url"
+ ],
+ "title": "CreateUrlRequest",
+ "description": "Request body for creating a new shortened URL.\n\nAccepts ``url`` as an alias for ``long_url`` - the existing API supports both."
+ },
+ "CreateWebhookEndpointRequest": {
+ "properties": {
+ "url": {
+ "type": "string",
+ "maxLength": 2048,
+ "title": "Url",
+ "description": "HTTPS URL that receives event deliveries.",
+ "examples": [
+ "https://example.com/hooks/spoo"
+ ]
+ },
+ "events": {
+ "items": {
+ "type": "string"
},
- "MessageResponse": {
- "properties": {
- "success": {
- "type": "boolean",
- "title": "Success"
- },
- "message": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Message"
- }
+ "type": "array",
+ "maxItems": 32,
+ "minItems": 1,
+ "title": "Events",
+ "description": "Event types or patterns to subscribe to. Supports `link.*`-style category wildcards and `*` for everything.",
+ "examples": [
+ [
+ "link.clicked",
+ "link.expired"
+ ]
+ ]
+ },
+ "description": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 256
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Description"
+ },
+ "scope_links": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
},
- "type": "object",
- "required": [
- "success"
- ],
- "title": "MessageResponse",
- "description": "Generic success/message response returned by several endpoints."
+ "type": "array",
+ "maxItems": 256
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Scope Links",
+ "description": "Link IDs to scope deliveries to. Omit (or null) for all links, including ones created later."
+ },
+ "flavor": {
+ "$ref": "#/components/schemas/WebhookFlavor",
+ "description": "Payload presentation. `raw` is the documented contract.",
+ "default": "raw"
+ }
+ },
+ "type": "object",
+ "required": [
+ "url",
+ "events"
+ ],
+ "title": "CreateWebhookEndpointRequest"
+ },
+ "CustomDomainDeleteResponse": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "ID of the revoked domain."
+ },
+ "fqdn": {
+ "type": "string",
+ "title": "Fqdn",
+ "description": "The revoked fqdn."
+ },
+ "cascade": {
+ "type": "boolean",
+ "title": "Cascade",
+ "description": "Whether URLs on the domain were also deleted."
+ },
+ "urls_deleted": {
+ "type": "integer",
+ "title": "Urls Deleted",
+ "description": "URL count deleted by cascade (0 when cascade=false)."
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "fqdn",
+ "cascade",
+ "urls_deleted"
+ ],
+ "title": "CustomDomainDeleteResponse",
+ "description": "Response for domain revoke. ``urls_deleted`` is 0 unless cascade was true."
+ },
+ "CustomDomainListResponse": {
+ "properties": {
+ "items": {
+ "items": {
+ "$ref": "#/components/schemas/CustomDomainResponse"
},
- "OAuthProvidersResponse": {
- "properties": {
- "providers": {
- "items": {
- "additionalProperties": true,
- "type": "object"
- },
- "type": "array",
- "title": "Providers",
- "description": "List of linked OAuth providers with name, email, and linked_at"
- },
- "password_set": {
- "type": "boolean",
- "title": "Password Set",
- "description": "Whether the user has a password set (affects unlink eligibility)"
- }
- },
- "type": "object",
- "required": [
- "providers",
- "password_set"
- ],
- "title": "OAuthProvidersResponse",
- "description": "Response body for GET /oauth/providers (200)."
+ "type": "array",
+ "title": "Items"
+ },
+ "page": {
+ "type": "integer",
+ "title": "Page"
+ },
+ "pageSize": {
+ "type": "integer",
+ "title": "Pagesize"
+ },
+ "total": {
+ "type": "integer",
+ "title": "Total"
+ },
+ "hasNext": {
+ "type": "boolean",
+ "title": "Hasnext"
+ }
+ },
+ "type": "object",
+ "required": [
+ "items",
+ "page",
+ "pageSize",
+ "total",
+ "hasNext"
+ ],
+ "title": "CustomDomainListResponse",
+ "description": "Paginated list of the caller's custom domains."
+ },
+ "CustomDomainResponse": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "Server-generated domain ID."
+ },
+ "fqdn": {
+ "type": "string",
+ "title": "Fqdn",
+ "description": "Canonical fqdn (lowercased, trailing dot stripped)."
+ },
+ "status": {
+ "$ref": "#/components/schemas/DomainStatus"
+ },
+ "verification_method": {
+ "$ref": "#/components/schemas/VerificationMethod"
+ },
+ "dns_records": {
+ "items": {
+ "$ref": "#/components/schemas/DnsRecord"
},
- "RefreshResponse": {
- "properties": {
- "access_token": {
- "type": "string",
- "title": "Access Token",
- "description": "New JWT access token",
- "examples": [
- "eyJhbGciOiJIUzI1NiIs..."
- ]
- }
- },
- "type": "object",
- "required": [
- "access_token"
- ],
- "title": "RefreshResponse",
- "description": "Response body for POST /auth/refresh (200)."
+ "type": "array",
+ "title": "Dns Records",
+ "description": "DNS records the user must publish at their DNS provider."
+ },
+ "setup_notes": {
+ "items": {
+ "type": "string"
},
- "RegisterRequest": {
- "properties": {
- "email": {
- "type": "string",
- "title": "Email",
- "description": "Email address for the new account",
- "examples": [
- "newuser@example.com"
- ]
- },
- "password": {
- "type": "string",
- "title": "Password",
- "description": "Password (min 8 chars, must contain letter + number + special char)",
- "examples": [
- "MySecurePass123!"
- ]
- },
- "user_name": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "User Name",
- "description": "Display name (optional)",
- "examples": [
- "Jane Doe"
- ]
- }
- },
- "type": "object",
- "required": [
- "email",
- "password"
- ],
- "title": "RegisterRequest",
- "description": "Request body for POST /auth/register."
+ "type": "array",
+ "title": "Setup Notes",
+ "description": "Human-readable setup warnings/instructions specific to this domain."
+ },
+ "created_at": {
+ "type": "string",
+ "title": "Created At"
+ },
+ "updated_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Updated At"
+ },
+ "last_verified_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Verified At"
+ },
+ "last_verification_error": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Verification Error"
+ },
+ "root_redirect": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Root Redirect",
+ "description": "Destination for `GET /` on this domain. Honored only when status=ACTIVE."
+ },
+ "not_found_redirect": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Not Found Redirect",
+ "description": "Fallback for non-alias paths. Honored only when status=ACTIVE."
+ },
+ "custom_robots_txt": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Custom Robots Txt",
+ "description": "Override body served at /robots.txt. Honored only when status=ACTIVE."
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "fqdn",
+ "status",
+ "verification_method",
+ "created_at"
+ ],
+ "title": "CustomDomainResponse",
+ "description": "A single custom domain - covers create, get, verify, and list responses."
+ },
+ "DeleteUrlResponse": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "Confirmation message.",
+ "examples": [
+ "URL deleted"
+ ]
+ },
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "ID of the deleted URL.",
+ "examples": [
+ "507f1f77bcf86cd799439011"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "message",
+ "id"
+ ],
+ "title": "DeleteUrlResponse",
+ "description": "Response body for DELETE /api/v1/urls/{url_id}."
+ },
+ "DeliveriesListResponse": {
+ "properties": {
+ "deliveries": {
+ "items": {
+ "$ref": "#/components/schemas/WebhookDeliveryResponse"
},
- "RegisterResponse": {
- "properties": {
- "access_token": {
- "type": "string",
- "title": "Access Token",
- "description": "JWT access token",
- "examples": [
- "eyJhbGciOiJIUzI1NiIs..."
- ]
- },
- "user": {
- "$ref": "#/components/schemas/UserProfileResponse",
- "description": "Newly created user's profile"
- },
- "requires_verification": {
- "type": "boolean",
- "title": "Requires Verification",
- "description": "Whether email verification is required before accessing protected resources"
- },
- "verification_sent": {
- "type": "boolean",
- "title": "Verification Sent",
- "description": "Whether the verification email was sent successfully"
- }
+ "type": "array",
+ "title": "Deliveries"
+ },
+ "total": {
+ "type": "integer",
+ "title": "Total"
+ },
+ "page": {
+ "type": "integer",
+ "title": "Page"
+ },
+ "page_size": {
+ "type": "integer",
+ "title": "Page Size"
+ }
+ },
+ "type": "object",
+ "required": [
+ "deliveries",
+ "total",
+ "page",
+ "page_size"
+ ],
+ "title": "DeliveriesListResponse"
+ },
+ "DeliveryAttemptResponse": {
+ "properties": {
+ "attempted_at": {
+ "type": "integer",
+ "title": "Attempted At"
+ },
+ "status_code": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Status Code"
+ },
+ "duration_ms": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Duration Ms"
+ },
+ "error": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Error"
+ },
+ "response_body": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Response Body"
+ }
+ },
+ "type": "object",
+ "required": [
+ "attempted_at"
+ ],
+ "title": "DeliveryAttemptResponse"
+ },
+ "DeliveryStatus": {
+ "type": "string",
+ "enum": [
+ "pending",
+ "success",
+ "failed"
+ ],
+ "title": "DeliveryStatus"
+ },
+ "DeviceRefreshRequest": {
+ "properties": {
+ "refresh_token": {
+ "type": "string",
+ "minLength": 1,
+ "title": "Refresh Token",
+ "description": "JWT refresh token issued by /auth/device/token"
+ }
+ },
+ "type": "object",
+ "required": [
+ "refresh_token"
+ ],
+ "title": "DeviceRefreshRequest",
+ "description": "Request body for POST /auth/device/refresh."
+ },
+ "DeviceRefreshResponse": {
+ "properties": {
+ "access_token": {
+ "type": "string",
+ "title": "Access Token",
+ "description": "New JWT access token"
+ },
+ "refresh_token": {
+ "type": "string",
+ "title": "Refresh Token",
+ "description": "New JWT refresh token"
+ }
+ },
+ "type": "object",
+ "required": [
+ "access_token",
+ "refresh_token"
+ ],
+ "title": "DeviceRefreshResponse",
+ "description": "Response body for POST /auth/device/refresh (200)."
+ },
+ "DeviceTokenRequest": {
+ "properties": {
+ "code": {
+ "type": "string",
+ "maxLength": 128,
+ "minLength": 1,
+ "title": "Code",
+ "description": "One-time auth code from the device callback page"
+ },
+ "code_verifier": {
+ "type": "string",
+ "maxLength": 128,
+ "minLength": 43,
+ "pattern": "^[A-Za-z0-9\\-._~]+$",
+ "title": "Code Verifier",
+ "description": "PKCE code verifier (RFC 7636). Must hash (S256) to the code_challenge sent at /auth/device/login."
+ }
+ },
+ "type": "object",
+ "required": [
+ "code",
+ "code_verifier"
+ ],
+ "title": "DeviceTokenRequest",
+ "description": "Request body for POST /auth/device/token."
+ },
+ "DeviceTokenResponse": {
+ "properties": {
+ "access_token": {
+ "type": "string",
+ "title": "Access Token",
+ "description": "JWT access token"
+ },
+ "refresh_token": {
+ "type": "string",
+ "title": "Refresh Token",
+ "description": "JWT refresh token"
+ },
+ "user": {
+ "$ref": "#/components/schemas/UserProfileResponse",
+ "description": "User profile"
+ }
+ },
+ "type": "object",
+ "required": [
+ "access_token",
+ "refresh_token",
+ "user"
+ ],
+ "title": "DeviceTokenResponse",
+ "description": "Response body for POST /auth/device/token (200)."
+ },
+ "DnsRecord": {
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "CNAME",
+ "TXT",
+ "A"
+ ],
+ "title": "Type",
+ "description": "Record type."
+ },
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "Record name (subdomain or @ for apex)."
+ },
+ "value": {
+ "type": "string",
+ "title": "Value",
+ "description": "Record value."
+ },
+ "purpose": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Purpose",
+ "description": "Human-readable note explaining why this record is needed."
+ }
+ },
+ "type": "object",
+ "required": [
+ "type",
+ "name",
+ "value"
+ ],
+ "title": "DnsRecord",
+ "description": "One DNS record the user must publish to complete setup."
+ },
+ "DomainStatus": {
+ "type": "string",
+ "enum": [
+ "pending",
+ "verifying",
+ "active",
+ "suspended",
+ "revoked"
+ ],
+ "title": "DomainStatus",
+ "description": "Lifecycle state of a custom domain registration."
+ },
+ "EmojiEntry": {
+ "properties": {
+ "c": {
+ "type": "string",
+ "title": "C",
+ "description": "Raw canonical emoji character (no U+FE0F variation selector), matching how aliases are stored and echoed."
+ },
+ "n": {
+ "type": "string",
+ "title": "N",
+ "description": "Human-readable name, lowercased with spaces (e.g. \"rocket\"). The primary search key."
+ },
+ "g": {
+ "type": "string",
+ "title": "G",
+ "description": "Canonical Unicode category display name (e.g. \"Smileys & Emotion\"), for the picker's category tabs. Entries in the array are already sorted by canonical group and within-group order, so a picker opens on Smileys rather than symbols."
+ },
+ "gen": {
+ "type": "boolean",
+ "title": "Gen",
+ "description": "Whether this emoji is in the auto-generation pool. Filter gen=true for the subset the server auto-generates."
+ },
+ "k": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
},
- "type": "object",
- "required": [
- "access_token",
- "user",
- "requires_verification",
- "verification_sent"
- ],
- "title": "RegisterResponse",
- "description": "Response body for POST /auth/register (201)."
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "K",
+ "description": "Extra search aliases when the source lists any (e.g. \"tada\" for the party popper); omitted otherwise. Name search is the floor; these only widen it."
+ }
+ },
+ "type": "object",
+ "required": [
+ "c",
+ "n",
+ "g",
+ "gen"
+ ],
+ "title": "EmojiEntry",
+ "description": "One accepted emoji, enriched for client-side search.\n\nNames and aliases come from the same pinned ``emoji`` package the set\nitself is derived from, so there is no second dataset to keep in sync."
+ },
+ "EmojiSetResponse": {
+ "properties": {
+ "accept_max_version": {
+ "type": "number",
+ "title": "Accept Max Version",
+ "description": "Newest Unicode emoji version a custom alias may use."
+ },
+ "generate_max_version": {
+ "type": "number",
+ "title": "Generate Max Version",
+ "description": "Cap for auto-generated emoji aliases (lower, for older platform coverage)."
+ },
+ "max_graphemes": {
+ "type": "integer",
+ "title": "Max Graphemes",
+ "description": "Maximum number of emoji graphemes allowed in one alias."
+ },
+ "emoji": {
+ "items": {
+ "$ref": "#/components/schemas/EmojiEntry"
},
- "RequestPasswordResetRequest": {
- "properties": {
- "email": {
- "type": "string",
- "title": "Email",
- "description": "Email address of the account to reset",
- "examples": [
- "user@example.com"
- ]
- }
- },
- "type": "object",
- "required": [
- "email"
- ],
- "title": "RequestPasswordResetRequest",
- "description": "Request body for POST /auth/request-password-reset."
+ "type": "array",
+ "title": "Emoji",
+ "description": "Every single-codepoint emoji a user may choose, at the acceptance cap, each with its name and whether it is in the generation pool. This is the picker's list. Skin-tone variants are NOT enumerated: the base emoji suffices and skin tone is a client-side modifier appended to the base."
+ }
+ },
+ "type": "object",
+ "required": [
+ "accept_max_version",
+ "generate_max_version",
+ "max_graphemes",
+ "emoji"
+ ],
+ "title": "EmojiSetResponse",
+ "description": "The accepted emoji catalogue and its policy caps.\n\nEmoji values are RAW characters in canonical form (no ``U+FE0F``). Each\ncarries its canonical Unicode category (``g``) and the array is ordered by\ncanonical group and within-group order, so a picker can render category\ntabs and open on Smileys rather than symbols."
+ },
+ "ErrorResponse": {
+ "properties": {
+ "error": {
+ "type": "string",
+ "title": "Error"
+ },
+ "code": {
+ "type": "string",
+ "title": "Code"
+ },
+ "field": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Field"
+ },
+ "details": {
+ "anyOf": [
+ {},
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Details"
+ }
+ },
+ "type": "object",
+ "required": [
+ "error",
+ "code"
+ ],
+ "title": "ErrorResponse",
+ "description": "Standard error JSON body produced by the AppError exception handler."
+ },
+ "EventTypeInfoResponse": {
+ "properties": {
+ "type": {
+ "type": "string",
+ "title": "Type"
+ },
+ "category": {
+ "type": "string",
+ "title": "Category"
+ },
+ "description": {
+ "type": "string",
+ "title": "Description"
+ },
+ "frequency": {
+ "type": "string",
+ "title": "Frequency"
+ },
+ "sample": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Sample"
+ }
+ },
+ "type": "object",
+ "required": [
+ "type",
+ "category",
+ "description",
+ "frequency",
+ "sample"
+ ],
+ "title": "EventTypeInfoResponse"
+ },
+ "EventTypesResponse": {
+ "properties": {
+ "event_types": {
+ "items": {
+ "$ref": "#/components/schemas/EventTypeInfoResponse"
},
- "ResetPasswordRequest": {
- "properties": {
- "email": {
- "type": "string",
- "title": "Email",
- "description": "Email address of the account",
- "examples": [
- "user@example.com"
- ]
- },
- "code": {
- "type": "string",
- "title": "Code",
- "description": "6-digit OTP from password reset email",
- "examples": [
- "123456"
- ]
- },
- "password": {
- "type": "string",
- "title": "Password",
- "description": "New password (min 8 chars, must contain letter + number + special char)",
- "examples": [
- "NewSecurePass456!"
- ]
- }
- },
- "type": "object",
- "required": [
- "email",
- "code",
- "password"
- ],
- "title": "ResetPasswordRequest",
- "description": "Request body for POST /auth/reset-password."
+ "type": "array",
+ "title": "Event Types"
+ }
+ },
+ "type": "object",
+ "required": [
+ "event_types"
+ ],
+ "title": "EventTypesResponse"
+ },
+ "FeatureState": {
+ "type": "string",
+ "enum": [
+ "enabled",
+ "locked",
+ "hidden"
+ ],
+ "title": "FeatureState",
+ "description": "What a client should do with a gated feature's UI.\n\nENABLED - render it; the account has the feature.\nHIDDEN - render nothing; the feature does not exist for this account.\nLOCKED - render it in a locked/upsell state. Reserved: no backend\n policy emits it until paid plans exist, but it is part of the\n contract from day one so entitlements later are a data change,\n not an API version bump."
+ },
+ "FeaturesResponse": {
+ "properties": {
+ "features": {
+ "additionalProperties": {
+ "$ref": "#/components/schemas/FeatureState"
},
- "SendVerificationResponse": {
- "properties": {
- "success": {
- "type": "boolean",
- "title": "Success",
- "description": "Whether the verification email was sent"
- },
- "message": {
- "type": "string",
- "title": "Message",
- "description": "Human-readable status message"
- },
- "expires_in": {
- "type": "integer",
- "title": "Expires In",
- "description": "OTP expiry duration in seconds",
- "examples": [
- 600
- ]
- }
- },
- "type": "object",
- "required": [
- "success",
- "message",
- "expires_in"
- ],
- "title": "SendVerificationResponse",
- "description": "Response body for POST /auth/send-verification (200)."
+ "type": "object",
+ "title": "Features"
+ }
+ },
+ "type": "object",
+ "required": [
+ "features"
+ ],
+ "title": "FeaturesResponse",
+ "description": "Per-feature availability for the authenticated account.\n\nKeys are the exposed feature names (``services.feature_flag_service.\nEXPOSED_FEATURES``); values tell the client what to render. Clients\nmust treat unknown keys as informational and missing keys as HIDDEN,\nso the feature list can grow without breaking older frontends."
+ },
+ "HTTPValidationError": {
+ "properties": {
+ "detail": {
+ "items": {
+ "$ref": "#/components/schemas/ValidationError"
},
- "SetPasswordRequest": {
- "properties": {
- "password": {
- "type": "string",
- "title": "Password",
- "description": "New password (min 8 chars, must contain letter + number + special char)",
- "examples": [
- "MySecurePass123!"
- ]
- }
+ "type": "array",
+ "title": "Detail"
+ }
+ },
+ "type": "object",
+ "title": "HTTPValidationError"
+ },
+ "LayoutResponse": {
+ "properties": {
+ "layout": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Layout",
+ "description": "Saved layout doc, or null when no override exists"
+ }
+ },
+ "type": "object",
+ "title": "LayoutResponse"
+ },
+ "LoginRequest": {
+ "properties": {
+ "email": {
+ "type": "string",
+ "format": "email",
+ "title": "Email",
+ "description": "Account email address",
+ "examples": [
+ "user@example.com"
+ ]
+ },
+ "password": {
+ "type": "string",
+ "maxLength": 255,
+ "title": "Password",
+ "description": "Account password",
+ "examples": [
+ "MySecurePass123!"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "email",
+ "password"
+ ],
+ "title": "LoginRequest",
+ "description": "Request body for POST /auth/login."
+ },
+ "LoginResponse": {
+ "properties": {
+ "access_token": {
+ "type": "string",
+ "title": "Access Token",
+ "description": "JWT access token",
+ "examples": [
+ "eyJhbGciOiJIUzI1NiIs..."
+ ]
+ },
+ "user": {
+ "$ref": "#/components/schemas/UserProfileResponse",
+ "description": "Authenticated user's profile"
+ }
+ },
+ "type": "object",
+ "required": [
+ "access_token",
+ "user"
+ ],
+ "title": "LoginResponse",
+ "description": "Response body for POST /auth/login (200)."
+ },
+ "LogoutResponse": {
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "title": "Success",
+ "description": "Always true on successful logout"
+ }
+ },
+ "type": "object",
+ "required": [
+ "success"
+ ],
+ "title": "LogoutResponse",
+ "description": "Response body for POST /auth/logout (200)."
+ },
+ "MeResponse": {
+ "properties": {
+ "user": {
+ "$ref": "#/components/schemas/UserProfileResponse",
+ "description": "Current authenticated user's profile"
+ }
+ },
+ "type": "object",
+ "required": [
+ "user"
+ ],
+ "title": "MeResponse",
+ "description": "Response body for GET /auth/me (200)."
+ },
+ "MessageResponse": {
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "title": "Success"
+ },
+ "message": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Message"
+ }
+ },
+ "type": "object",
+ "required": [
+ "success"
+ ],
+ "title": "MessageResponse",
+ "description": "Generic success/message response returned by several endpoints."
+ },
+ "MetaTagsRequest": {
+ "properties": {
+ "title": {
+ "type": "string",
+ "maxLength": 120,
+ "minLength": 1,
+ "title": "Title",
+ "description": "Preview headline (og:title). Required when meta_tags is set.",
+ "examples": [
+ "We just launched 🎉"
+ ]
+ },
+ "description": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 240
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Description",
+ "description": "og:description - roughly 200 chars render on most platforms."
+ },
+ "image": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 700000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Image",
+ "description": "og:image - an https URL, or a `data:image/png|jpeg|webp;base64,` URI which is validated and stored on spoo's CDN. 1200x630 recommended; keep it under 300KB or WhatsApp silently drops it; SVG is rejected (no preview crawler renders it).",
+ "examples": [
+ "https://example.com/og.png"
+ ]
+ },
+ "color": {
+ "anyOf": [
+ {
+ "type": "string",
+ "pattern": "^#[0-9a-fA-F]{6}$"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Color",
+ "description": "Accent color shown on Discord embeds (theme-color).",
+ "examples": [
+ "#FF5733"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "title"
+ ],
+ "title": "MetaTagsRequest",
+ "description": "Custom social preview (og:title / og:description / og:image / theme-color)."
+ },
+ "MetaTagsResponse": {
+ "properties": {
+ "title": {
+ "type": "string",
+ "title": "Title",
+ "description": "og:title.",
+ "examples": [
+ "We just launched 🎉"
+ ]
+ },
+ "description": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Description",
+ "description": "og:description."
+ },
+ "image": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Image",
+ "description": "og:image URL."
+ },
+ "color": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Color",
+ "description": "Discord embed accent color.",
+ "examples": [
+ "#FF5733"
+ ]
+ },
+ "warnings": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
},
- "type": "object",
- "required": [
- "password"
- ],
- "title": "SetPasswordRequest",
- "description": "Request body for POST /auth/set-password.\n\nOnly applies to OAuth-only users who have not yet set a password."
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Warnings",
+ "description": "Non-fatal quality notes, e.g. an image WhatsApp may drop."
+ }
+ },
+ "type": "object",
+ "required": [
+ "title"
+ ],
+ "title": "MetaTagsResponse",
+ "description": "Custom social-preview settings on a URL (client-visible fields only)."
+ },
+ "MetadataResponse": {
+ "properties": {
+ "url": {
+ "type": "string",
+ "title": "Url",
+ "description": "The URL that was requested."
+ },
+ "final_url": {
+ "type": "string",
+ "title": "Final Url",
+ "description": "URL after following redirects."
+ },
+ "title": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Title"
+ },
+ "description": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Description"
+ },
+ "image": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Image",
+ "description": "Absolute https URL."
+ },
+ "color": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Color",
+ "description": "theme-color if #RRGGBB."
+ },
+ "site_name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Site Name"
+ },
+ "og": {
+ "additionalProperties": {
+ "type": "string"
},
- "StatsResponse": {
- "properties": {
- "scope": {
- "type": "string",
- "title": "Scope"
- },
- "filters": {
- "additionalProperties": true,
- "type": "object",
- "title": "Filters"
- },
- "group_by": {
- "items": {
- "type": "string"
- },
- "type": "array",
- "title": "Group By"
- },
- "timezone": {
- "type": "string",
- "title": "Timezone"
- },
- "time_range": {
- "$ref": "#/components/schemas/StatsTimeRange"
- },
- "summary": {
- "$ref": "#/components/schemas/StatsSummary"
- },
- "metrics": {
- "additionalProperties": {
- "items": {
- "additionalProperties": true,
- "type": "object"
- },
- "type": "array"
- },
- "type": "object",
- "title": "Metrics",
- "description": "Keyed by '{metric}_by_{dimension}' (e.g. 'clicks_by_browser', 'unique_clicks_by_time'). Each value is a list of data-point objects whose keys are the dimension name, the metric name, and '{metric}_percentage'."
- },
- "generated_at": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Generated At"
- },
- "api_version": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Api Version"
- },
- "short_code": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Short Code"
- },
- "time_bucket_info": {
- "anyOf": [
- {
- "$ref": "#/components/schemas/TimeBucketInfo"
- },
- {
- "type": "null"
- }
- ]
- },
- "computed_metrics": {
- "anyOf": [
- {
- "$ref": "#/components/schemas/ComputedMetrics"
- },
- {
- "type": "null"
- }
- ]
- }
- },
- "type": "object",
- "required": [
- "scope",
- "filters",
- "group_by",
- "timezone",
- "time_range",
- "summary"
- ],
- "title": "StatsResponse",
- "description": "Response body for GET /api/v1/stats.\n\n``metrics`` uses dynamic keys ({metric}_by_{dimension}), each mapping to a\nlist of data-point dicts. Optional fields (``short_code``,\n``time_bucket_info``, ``computed_metrics``) are absent when not applicable."
+ "type": "object",
+ "title": "Og"
+ },
+ "twitter": {
+ "additionalProperties": {
+ "type": "string"
},
- "StatsSummary": {
- "properties": {
- "total_clicks": {
- "type": "integer",
- "title": "Total Clicks"
- },
- "unique_clicks": {
- "type": "integer",
- "title": "Unique Clicks"
- },
- "first_click": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "First Click"
- },
- "last_click": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Last Click"
- },
- "avg_redirection_time": {
- "type": "number",
- "title": "Avg Redirection Time"
- }
- },
- "type": "object",
- "required": [
- "total_clicks",
- "unique_clicks",
- "avg_redirection_time"
- ],
- "title": "StatsSummary",
- "description": "Summary statistics block inside StatsResponse."
+ "type": "object",
+ "title": "Twitter"
+ },
+ "fetched_at": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Fetched At"
+ }
+ },
+ "type": "object",
+ "required": [
+ "url",
+ "final_url",
+ "fetched_at"
+ ],
+ "title": "MetadataResponse",
+ "description": "Parsed meta tags of a destination page.\n\n``title``/``description``/``image``/``color``/``site_name`` are the\nnormalized best-picks (og → twitter → html fallbacks) ready to prefill\na link's ``meta_tags``; ``og``/``twitter`` carry the raw families."
+ },
+ "OAuthProvider": {
+ "type": "string",
+ "enum": [
+ "google",
+ "github",
+ "discord"
+ ],
+ "title": "OAuthProvider",
+ "description": "Supported OAuth providers."
+ },
+ "OAuthProviderDetail": {
+ "properties": {
+ "provider": {
+ "$ref": "#/components/schemas/OAuthProvider",
+ "description": "Provider name",
+ "examples": [
+ "google"
+ ]
+ },
+ "email": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Email",
+ "description": "Email from provider"
+ },
+ "email_verified": {
+ "type": "boolean",
+ "title": "Email Verified",
+ "description": "Email verified by provider",
+ "default": false
+ },
+ "linked_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Linked At",
+ "description": "When the provider was linked"
+ },
+ "profile": {
+ "$ref": "#/components/schemas/ProviderProfile",
+ "description": "Provider profile (name, picture)"
+ }
+ },
+ "type": "object",
+ "required": [
+ "provider"
+ ],
+ "title": "OAuthProviderDetail",
+ "description": "Detailed OAuth provider entry for the providers list endpoint."
+ },
+ "OAuthProvidersResponse": {
+ "properties": {
+ "providers": {
+ "items": {
+ "$ref": "#/components/schemas/OAuthProviderDetail"
},
- "StatsTimeRange": {
- "properties": {
- "start_date": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Start Date"
- },
- "end_date": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "End Date"
- }
- },
- "type": "object",
- "title": "StatsTimeRange",
- "description": "Time range metadata inside StatsResponse."
+ "type": "array",
+ "title": "Providers",
+ "description": "List of linked OAuth providers with name, email, and linked_at"
+ },
+ "password_set": {
+ "type": "boolean",
+ "title": "Password Set",
+ "description": "Whether the user has a password set (affects unlink eligibility)"
+ }
+ },
+ "type": "object",
+ "required": [
+ "providers",
+ "password_set"
+ ],
+ "title": "OAuthProvidersResponse",
+ "description": "Response body for GET /oauth/providers (200)."
+ },
+ "OnboardingCompleteRequest": {
+ "properties": {
+ "heard_from": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 64
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Heard From",
+ "description": "HDYHAU attribution answer, captured once at completion",
+ "examples": [
+ "GitHub"
+ ]
+ }
+ },
+ "type": "object",
+ "title": "OnboardingCompleteRequest",
+ "description": "Request body for POST /auth/onboarding/complete."
+ },
+ "OnboardingCompleteResponse": {
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "title": "Success",
+ "description": "Always true on success"
+ },
+ "onboarded_at": {
+ "type": "string",
+ "title": "Onboarded At",
+ "description": "When onboarding was completed (first completion wins)"
+ }
+ },
+ "type": "object",
+ "required": [
+ "success",
+ "onboarded_at"
+ ],
+ "title": "OnboardingCompleteResponse",
+ "description": "Response body for POST /auth/onboarding/complete (200)."
+ },
+ "OnboardingStateRequest": {
+ "properties": {
+ "step": {
+ "type": "string",
+ "enum": [
+ "welcome",
+ "path",
+ "link",
+ "api",
+ "domain",
+ "apps",
+ "recap"
+ ],
+ "title": "Step",
+ "description": "Wizard step the user is currently on",
+ "examples": [
+ "domain"
+ ]
+ },
+ "path": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "links",
+ "api"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Path",
+ "description": "Chosen onboarding path, once the user has forked",
+ "examples": [
+ "links"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "step"
+ ],
+ "title": "OnboardingStateRequest",
+ "description": "Request body for PUT /auth/onboarding."
+ },
+ "OnboardingStateResponse": {
+ "properties": {
+ "step": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Step",
+ "description": "Stored wizard step",
+ "examples": [
+ "link"
+ ]
+ },
+ "path": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Path",
+ "description": "Chosen path (links or api)",
+ "examples": [
+ "links"
+ ]
+ }
+ },
+ "type": "object",
+ "title": "OnboardingStateResponse",
+ "description": "Response body for GET/PUT /auth/onboarding (200).\n\nA resume pointer, nothing more. Empty (step=null) means nothing to\nresume: never started, expired, or already completed - completion is\na permanent account fact exposed as ``user.onboarded_at`` on\n/auth/me, not part of this cache."
+ },
+ "PreviewDestination": {
+ "properties": {
+ "url": {
+ "type": "string",
+ "title": "Url"
+ },
+ "domain": {
+ "type": "string",
+ "title": "Domain"
+ },
+ "path": {
+ "type": "string",
+ "title": "Path"
+ },
+ "is_https": {
+ "type": "boolean",
+ "title": "Is Https"
+ }
+ },
+ "type": "object",
+ "required": [
+ "url",
+ "domain",
+ "path",
+ "is_https"
+ ],
+ "title": "PreviewDestination",
+ "description": "A destination URL split into display parts."
+ },
+ "PreviewGeoDestination": {
+ "properties": {
+ "url": {
+ "type": "string",
+ "title": "Url"
+ },
+ "domain": {
+ "type": "string",
+ "title": "Domain"
+ },
+ "path": {
+ "type": "string",
+ "title": "Path"
+ },
+ "is_https": {
+ "type": "boolean",
+ "title": "Is Https"
+ },
+ "countries": {
+ "items": {
+ "type": "string"
},
- "TimeBucketInfo": {
- "properties": {
- "strategy": {
- "type": "string",
- "title": "Strategy"
- },
- "mongo_format": {
- "type": "string",
- "title": "Mongo Format"
- },
- "display_format": {
- "type": "string",
- "title": "Display Format"
- },
- "timezone": {
- "type": "string",
- "title": "Timezone"
- },
- "interval_minutes": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Interval Minutes"
- }
+ "type": "array",
+ "title": "Countries"
+ }
+ },
+ "type": "object",
+ "required": [
+ "url",
+ "domain",
+ "path",
+ "is_https",
+ "countries"
+ ],
+ "title": "PreviewGeoDestination",
+ "description": "One geo-rule destination group.\n\n``countries`` are ISO 3166-1 alpha-2 codes, sorted ascending - every\nrule is listed, nothing summarized (anti-cloaking transparency)."
+ },
+ "ProfilePictureMessageResponse": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "title": "Message"
+ }
+ },
+ "type": "object",
+ "required": [
+ "message"
+ ],
+ "title": "ProfilePictureMessageResponse"
+ },
+ "ProviderProfile": {
+ "properties": {
+ "name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name"
+ },
+ "picture": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Picture"
+ }
+ },
+ "type": "object",
+ "title": "ProviderProfile",
+ "description": "Nested profile data stored per OAuth provider."
+ },
+ "PublicLinkFacts": {
+ "properties": {
+ "alias": {
+ "type": "string",
+ "title": "Alias"
+ },
+ "short_url": {
+ "type": "string",
+ "title": "Short Url"
+ },
+ "long_url": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Long Url"
+ },
+ "created_at": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date-time"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Created At"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "active",
+ "inactive",
+ "expired",
+ "blocked"
+ ],
+ "title": "Status"
+ },
+ "max_clicks": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Max Clicks"
+ },
+ "block_bots": {
+ "type": "boolean",
+ "title": "Block Bots"
+ },
+ "password_protected": {
+ "type": "boolean",
+ "title": "Password Protected"
+ }
+ },
+ "type": "object",
+ "required": [
+ "alias",
+ "short_url",
+ "status",
+ "block_bots",
+ "password_protected"
+ ],
+ "title": "PublicLinkFacts",
+ "description": "Public facts about the link shown above the charts."
+ },
+ "PublicPreviewResponse": {
+ "properties": {
+ "generation": {
+ "type": "string",
+ "enum": [
+ "v1",
+ "v2"
+ ],
+ "title": "Generation"
+ },
+ "alias": {
+ "type": "string",
+ "title": "Alias"
+ },
+ "short_url": {
+ "type": "string",
+ "title": "Short Url"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "active",
+ "inactive",
+ "expired",
+ "blocked"
+ ],
+ "title": "Status"
+ },
+ "created_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Created At"
+ },
+ "password_protected": {
+ "type": "boolean",
+ "title": "Password Protected"
+ },
+ "destination": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/PreviewDestination"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "geo_destinations": {
+ "anyOf": [
+ {
+ "items": {
+ "$ref": "#/components/schemas/PreviewGeoDestination"
},
- "type": "object",
- "required": [
- "strategy",
- "mongo_format",
- "display_format",
- "timezone"
- ],
- "title": "TimeBucketInfo",
- "description": "Time bucketing metadata \u2014 only present when 'time' is in group_by."
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Geo Destinations"
+ }
+ },
+ "type": "object",
+ "required": [
+ "generation",
+ "alias",
+ "short_url",
+ "status",
+ "created_at",
+ "password_protected",
+ "destination",
+ "geo_destinations"
+ ],
+ "title": "PublicPreviewResponse",
+ "description": "Response body for the public link preview endpoint.\n\n``destination`` and ``geo_destinations`` are non-null only while the\nlink is active and not password-protected - the preview never reveals\na destination the redirect would refuse to serve."
+ },
+ "PublicStatsBody": {
+ "properties": {
+ "password": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 200
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Password",
+ "description": "Password for a password-protected link's stats."
+ }
+ },
+ "type": "object",
+ "title": "PublicStatsBody",
+ "description": "Optional JSON body carrying the stats-page password."
+ },
+ "PublicStatsResponse": {
+ "properties": {
+ "generation": {
+ "type": "string",
+ "enum": [
+ "v1",
+ "v2"
+ ],
+ "title": "Generation"
+ },
+ "link": {
+ "$ref": "#/components/schemas/PublicLinkFacts"
+ },
+ "stats": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Stats",
+ "description": "The modern stats wire shape (same as GET /api/v1/stats): summary, metrics keyed '{metric}_by_{dimension}', time_range, time_bucket_info, computed_metrics. v1 links carry a 'clicks_by_bots' dimension and no 'city'/'device'; v2 the reverse."
+ }
+ },
+ "type": "object",
+ "required": [
+ "generation",
+ "link",
+ "stats"
+ ],
+ "title": "PublicStatsResponse",
+ "description": "Response body for GET|POST /api/v1/public/stats/{short_code}."
+ },
+ "PutLayoutRequest": {
+ "properties": {
+ "layout": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Layout",
+ "description": "Opaque layout document owned by the client. The server stores it verbatim; versioning happens inside the document.",
+ "examples": [
+ {
+ "version": 1,
+ "widgets": []
+ }
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "layout"
+ ],
+ "title": "PutLayoutRequest"
+ },
+ "RefreshResponse": {
+ "properties": {
+ "access_token": {
+ "type": "string",
+ "title": "Access Token",
+ "description": "New JWT access token",
+ "examples": [
+ "eyJhbGciOiJIUzI1NiIs..."
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "access_token"
+ ],
+ "title": "RefreshResponse",
+ "description": "Response body for POST /auth/refresh (200)."
+ },
+ "RegisterRequest": {
+ "properties": {
+ "email": {
+ "type": "string",
+ "format": "email",
+ "title": "Email",
+ "description": "Email address for the new account",
+ "examples": [
+ "newuser@example.com"
+ ]
+ },
+ "password": {
+ "type": "string",
+ "maxLength": 128,
+ "title": "Password",
+ "description": "Password (min 8 chars, must contain letter + number + special char)",
+ "examples": [
+ "MySecurePass123!"
+ ]
+ },
+ "user_name": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 255,
+ "minLength": 1
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "User Name",
+ "description": "Display name (optional)",
+ "examples": [
+ "Jane Doe"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "email",
+ "password"
+ ],
+ "title": "RegisterRequest",
+ "description": "Request body for POST /auth/register."
+ },
+ "RegisterResponse": {
+ "properties": {
+ "access_token": {
+ "type": "string",
+ "title": "Access Token",
+ "description": "JWT access token",
+ "examples": [
+ "eyJhbGciOiJIUzI1NiIs..."
+ ]
+ },
+ "user": {
+ "$ref": "#/components/schemas/UserProfileResponse",
+ "description": "Newly created user's profile"
+ },
+ "requires_verification": {
+ "type": "boolean",
+ "title": "Requires Verification",
+ "description": "Whether email verification is required before accessing protected resources"
+ },
+ "verification_sent": {
+ "type": "boolean",
+ "title": "Verification Sent",
+ "description": "Whether the verification email was sent successfully"
+ }
+ },
+ "type": "object",
+ "required": [
+ "access_token",
+ "user",
+ "requires_verification",
+ "verification_sent"
+ ],
+ "title": "RegisterResponse",
+ "description": "Response body for POST /auth/register (201)."
+ },
+ "RejectedReportItem": {
+ "properties": {
+ "index": {
+ "type": "integer",
+ "title": "Index"
+ },
+ "input": {
+ "type": "string",
+ "title": "Input"
+ },
+ "code": {
+ "type": "string",
+ "enum": [
+ "invalid_input",
+ "not_found",
+ "duplicate_in_batch"
+ ],
+ "title": "Code"
+ }
+ },
+ "type": "object",
+ "required": [
+ "index",
+ "input",
+ "code"
+ ],
+ "title": "RejectedReportItem",
+ "description": "One rejected entry in the per-item breakdown.\n\n``index`` refers to the item's position in the submitted ``items``\narray; ``input`` echoes the raw ``code_or_url`` so bulk clients can\nline results up without keeping their own index map."
+ },
+ "ReportItemRequest": {
+ "properties": {
+ "code_or_url": {
+ "type": "string",
+ "maxLength": 2048,
+ "minLength": 1,
+ "title": "Code Or Url",
+ "description": "Bare short code or full short URL - custom-domain URLs included (e.g. `abc123`, `spoo.me/abc123`, `https://go.customer.com/deal`)",
+ "examples": [
+ "abc123",
+ "https://spoo.me/abc123"
+ ]
+ },
+ "reason": {
+ "$ref": "#/components/schemas/ReportReason",
+ "description": "Reporter-claimed reason (triage hint)"
+ },
+ "details": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 2000
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Details",
+ "description": "Optional free-text context"
+ },
+ "vector": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/ReportVector"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "How the link reached the reporter"
+ }
+ },
+ "type": "object",
+ "required": [
+ "code_or_url",
+ "reason"
+ ],
+ "title": "ReportItemRequest",
+ "description": "One reported link inside POST /api/v1/reports."
+ },
+ "ReportReason": {
+ "type": "string",
+ "enum": [
+ "phishing",
+ "malware",
+ "spam",
+ "illegal_content",
+ "other"
+ ],
+ "title": "ReportReason",
+ "description": "Reporter-claimed reason, aligned with the safety framework's harm\ntiers so intake slots into the funnel without a translation layer."
+ },
+ "ReportSubmissionResponse": {
+ "properties": {
+ "submission_id": {
+ "type": "string",
+ "title": "Submission Id",
+ "description": "Opaque submission reference"
+ },
+ "accepted": {
+ "type": "integer",
+ "title": "Accepted",
+ "description": "Number of items stored"
+ },
+ "rejected": {
+ "items": {
+ "$ref": "#/components/schemas/RejectedReportItem"
},
- "UpdateUrlRequest": {
- "properties": {
- "long_url": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Long Url",
- "description": "New destination URL. Must be a valid http:// or https:// URL.",
- "examples": [
- "https://example.com/updated/url"
- ]
- },
- "alias": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Alias",
- "description": "New custom short code. Pass `null` to keep existing. Must be unique and available.",
- "examples": [
- "newlink"
- ]
- },
- "password": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Password",
- "description": "New password. Pass `null` to remove password protection.",
- "examples": [
- "newPass@456"
- ]
- },
- "block_bots": {
- "anyOf": [
- {
- "type": "boolean"
- },
- {
- "type": "null"
- }
- ],
- "title": "Block Bots",
- "description": "Block known bot user agents. Pass `null` to keep existing setting."
- },
- "max_clicks": {
- "anyOf": [
- {
- "type": "integer",
- "minimum": 0.0
- },
- {
- "type": "null"
- }
- ],
- "title": "Max Clicks",
- "description": "New click limit. Pass `0` or `null` to remove the limit.",
- "examples": [
- 500
- ]
- },
- "expire_after": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Expire After",
- "description": "Expiration time. ISO 8601 string (e.g. `2025-12-31T23:59:59Z`) or Unix epoch seconds (e.g. `1735689599`). Pass `null` to remove.",
- "examples": [
- "2025-12-31T23:59:59Z",
- 1735689599
- ]
- },
- "private_stats": {
- "anyOf": [
- {
- "type": "boolean"
- },
- {
- "type": "null"
- }
- ],
- "title": "Private Stats",
- "description": "Make statistics private (only owner can view). Pass `null` to keep existing."
- },
- "status": {
- "anyOf": [
- {
- "type": "string",
- "enum": [
- "ACTIVE",
- "INACTIVE"
- ]
- },
- {
- "type": "null"
- }
- ],
- "title": "Status",
- "description": "URL status. ACTIVE enables redirects, INACTIVE disables them.",
- "examples": [
- "ACTIVE"
- ]
- }
- },
- "type": "object",
- "title": "UpdateUrlRequest",
- "description": "Request body for partially updating an existing shortened URL.\n\nAll fields are optional; only provided fields are updated.\nPass ``max_clicks=0`` or ``max_clicks=null`` to remove the limit.\nPass ``password=null`` (or omit) to remove password protection."
+ "type": "array",
+ "title": "Rejected",
+ "description": "Per-item rejections (empty when everything was accepted)"
+ }
+ },
+ "type": "object",
+ "required": [
+ "submission_id",
+ "accepted",
+ "rejected"
+ ],
+ "title": "ReportSubmissionResponse",
+ "description": "Response body for POST /api/v1/reports.\n\nBad codes don't sink the batch: accepted items are stored even when\nothers are rejected, and the breakdown says which and why."
+ },
+ "ReportVector": {
+ "type": "string",
+ "enum": [
+ "sms",
+ "email",
+ "dm",
+ "social",
+ "web",
+ "other"
+ ],
+ "title": "ReportVector",
+ "description": "How the link reached the reporter - the delivery-vector hint the\n451 page's scam-awareness story needs."
+ },
+ "RequestPasswordResetRequest": {
+ "properties": {
+ "email": {
+ "type": "string",
+ "format": "email",
+ "title": "Email",
+ "description": "Email address of the account to reset",
+ "examples": [
+ "user@example.com"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "email"
+ ],
+ "title": "RequestPasswordResetRequest",
+ "description": "Request body for POST /auth/request-password-reset."
+ },
+ "ResetPasswordRequest": {
+ "properties": {
+ "email": {
+ "type": "string",
+ "format": "email",
+ "title": "Email",
+ "description": "Email address of the account",
+ "examples": [
+ "user@example.com"
+ ]
+ },
+ "code": {
+ "type": "string",
+ "pattern": "^[0-9]{6}$",
+ "title": "Code",
+ "description": "6-digit OTP from password reset email",
+ "examples": [
+ "123456"
+ ]
+ },
+ "password": {
+ "type": "string",
+ "maxLength": 128,
+ "title": "Password",
+ "description": "New password (min 8 chars, must contain letter + number + special char)",
+ "examples": [
+ "NewSecurePass456!"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "email",
+ "code",
+ "password"
+ ],
+ "title": "ResetPasswordRequest",
+ "description": "Request body for POST /auth/reset-password."
+ },
+ "SendVerificationResponse": {
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "title": "Success",
+ "description": "Whether the verification email was sent"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "Human-readable status message"
+ },
+ "expires_in": {
+ "type": "integer",
+ "title": "Expires In",
+ "description": "OTP expiry duration in seconds",
+ "examples": [
+ 600
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "success",
+ "message",
+ "expires_in"
+ ],
+ "title": "SendVerificationResponse",
+ "description": "Response body for POST /auth/send-verification (200)."
+ },
+ "SetPasswordRequest": {
+ "properties": {
+ "password": {
+ "type": "string",
+ "maxLength": 128,
+ "title": "Password",
+ "description": "New password (min 8 chars, must contain letter + number + special char)",
+ "examples": [
+ "MySecurePass123!"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "password"
+ ],
+ "title": "SetPasswordRequest",
+ "description": "Request body for POST /auth/set-password.\n\nOnly applies to OAuth-only users who have not yet set a password."
+ },
+ "SetProfilePictureRequest": {
+ "properties": {
+ "picture_id": {
+ "type": "string",
+ "maxLength": 200,
+ "minLength": 1,
+ "title": "Picture Id"
+ }
+ },
+ "type": "object",
+ "required": [
+ "picture_id"
+ ],
+ "title": "SetProfilePictureRequest"
+ },
+ "StatsResponse": {
+ "properties": {
+ "scope": {
+ "$ref": "#/components/schemas/StatsScope"
+ },
+ "filters": {
+ "additionalProperties": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
},
- "UpdateUrlResponse": {
- "properties": {
- "id": {
- "type": "string",
- "title": "Id",
- "description": "MongoDB ObjectId of the URL.",
- "examples": [
- "507f1f77bcf86cd799439011"
- ]
- },
- "alias": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Alias",
- "description": "Short code.",
- "examples": [
- "mylink"
- ]
- },
- "long_url": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Long Url",
- "description": "Destination URL.",
- "examples": [
- "https://example.com/long/url"
- ]
- },
- "status": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Status",
- "description": "URL status.",
- "examples": [
- "ACTIVE"
- ]
- },
- "password_set": {
- "type": "boolean",
- "title": "Password Set",
- "description": "Whether the URL is password-protected."
- },
- "max_clicks": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Max Clicks",
- "description": "Click limit, or null if unlimited.",
- "examples": [
- 100
- ]
- },
- "expire_after": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Expire After",
- "description": "Expiration as Unix timestamp, or null.",
- "examples": [
- 1735689599
- ]
- },
- "block_bots": {
- "anyOf": [
- {
- "type": "boolean"
- },
- {
- "type": "null"
- }
- ],
- "title": "Block Bots",
- "description": "Whether bot blocking is enabled."
- },
- "private_stats": {
- "anyOf": [
- {
- "type": "boolean"
- },
- {
- "type": "null"
- }
- ],
- "title": "Private Stats",
- "description": "Whether statistics are private."
- },
- "updated_at": {
- "type": "integer",
- "title": "Updated At",
- "description": "Last update time as Unix timestamp.",
- "examples": [
- 1704067200
- ]
- }
- },
- "type": "object",
- "required": [
- "id",
- "password_set",
- "updated_at"
- ],
- "title": "UpdateUrlResponse",
- "description": "Response body after a successful URL update (PATCH /api/v1/urls/{url_id})."
+ "type": "object",
+ "title": "Filters"
+ },
+ "group_by": {
+ "items": {
+ "type": "string"
},
- "UpdateUrlStatusRequest": {
- "properties": {
- "status": {
- "type": "string",
- "enum": [
- "ACTIVE",
- "INACTIVE"
- ],
- "title": "Status",
- "description": "New status for the URL. `ACTIVE` enables redirects, `INACTIVE` disables them.",
- "examples": [
- "ACTIVE"
- ]
- }
- },
- "type": "object",
- "required": [
- "status"
- ],
- "title": "UpdateUrlStatusRequest",
- "description": "Request body for updating only the status of a shortened URL."
+ "type": "array",
+ "title": "Group By"
+ },
+ "timezone": {
+ "type": "string",
+ "title": "Timezone"
+ },
+ "time_range": {
+ "$ref": "#/components/schemas/StatsTimeRange"
+ },
+ "summary": {
+ "$ref": "#/components/schemas/StatsSummary"
+ },
+ "metrics": {
+ "additionalProperties": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "type": "array"
},
- "UrlListItem": {
- "properties": {
- "id": {
- "type": "string",
- "title": "Id"
- },
- "alias": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Alias"
- },
- "long_url": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Long Url"
- },
- "status": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Status"
- },
- "created_at": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Created At"
- },
- "expire_after": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Expire After"
- },
- "max_clicks": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Max Clicks"
- },
- "private_stats": {
- "anyOf": [
- {
- "type": "boolean"
- },
- {
- "type": "null"
- }
- ],
- "title": "Private Stats"
- },
- "block_bots": {
- "anyOf": [
- {
- "type": "boolean"
- },
- {
- "type": "null"
- }
- ],
- "title": "Block Bots"
- },
- "password_set": {
- "type": "boolean",
- "title": "Password Set"
- },
- "total_clicks": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Total Clicks"
- },
- "last_click": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Last Click"
- }
+ "type": "object",
+ "title": "Metrics",
+ "description": "Keyed by '{metric}_by_{dimension}' (e.g. 'clicks_by_browser', 'unique_clicks_by_time'). Each value is a list of data-point objects whose keys are the dimension name, the metric name, and '{metric}_percentage'."
+ },
+ "generated_at": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date-time"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Generated At"
+ },
+ "api_version": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Api Version"
+ },
+ "short_code": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Short Code"
+ },
+ "time_bucket_info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/TimeBucketInfo"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "computed_metrics": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/ComputedMetrics"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "scope",
+ "filters",
+ "group_by",
+ "timezone",
+ "time_range",
+ "summary"
+ ],
+ "title": "StatsResponse",
+ "description": "Response body for GET /api/v1/stats.\n\n``metrics`` uses dynamic keys ({metric}_by_{dimension}), each mapping to a\nlist of data-point dicts. Optional fields (``short_code``,\n``time_bucket_info``, ``computed_metrics``) are absent when not applicable."
+ },
+ "StatsScope": {
+ "type": "string",
+ "enum": [
+ "all",
+ "anon"
+ ],
+ "title": "StatsScope",
+ "description": "Stats query scope."
+ },
+ "StatsSummary": {
+ "properties": {
+ "total_clicks": {
+ "type": "integer",
+ "title": "Total Clicks"
+ },
+ "unique_clicks": {
+ "type": "integer",
+ "title": "Unique Clicks"
+ },
+ "first_click": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date-time"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "First Click"
+ },
+ "last_click": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date-time"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Click"
+ },
+ "avg_redirection_time": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Avg Redirection Time"
+ }
+ },
+ "type": "object",
+ "required": [
+ "total_clicks",
+ "unique_clicks"
+ ],
+ "title": "StatsSummary",
+ "description": "Summary statistics block inside StatsResponse."
+ },
+ "StatsTimeRange": {
+ "properties": {
+ "start_date": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date-time"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Start Date"
+ },
+ "end_date": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date-time"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "End Date"
+ }
+ },
+ "type": "object",
+ "title": "StatsTimeRange",
+ "description": "Time range metadata inside StatsResponse."
+ },
+ "TestWebhookRequest": {
+ "properties": {
+ "event_type": {
+ "type": "string",
+ "title": "Event Type",
+ "description": "Any catalog event type - its documented sample payload is sent through the real pipeline - or `webhook.test`.",
+ "default": "webhook.test",
+ "examples": [
+ "link.clicked"
+ ]
+ }
+ },
+ "type": "object",
+ "title": "TestWebhookRequest"
+ },
+ "TimeBucketInfo": {
+ "properties": {
+ "strategy": {
+ "type": "string",
+ "title": "Strategy"
+ },
+ "mongo_format": {
+ "type": "string",
+ "title": "Mongo Format"
+ },
+ "display_format": {
+ "type": "string",
+ "title": "Display Format"
+ },
+ "timezone": {
+ "type": "string",
+ "title": "Timezone"
+ },
+ "interval_minutes": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Interval Minutes"
+ }
+ },
+ "type": "object",
+ "required": [
+ "strategy",
+ "mongo_format",
+ "display_format",
+ "timezone"
+ ],
+ "title": "TimeBucketInfo",
+ "description": "Time bucketing metadata - only present when 'time' is in group_by."
+ },
+ "UpdateCustomDomainRequest": {
+ "properties": {
+ "root_redirect": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 2083,
+ "minLength": 1,
+ "format": "uri"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Root Redirect",
+ "description": "Destination URL for `GET /` on the custom domain. Returns 302. Pass `null` to clear. Field omitted leaves current value.",
+ "examples": [
+ "https://acme.com/landing"
+ ]
+ },
+ "not_found_redirect": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 2083,
+ "minLength": 1,
+ "format": "uri"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Not Found Redirect",
+ "description": "Fallback URL for any path not matching an alias. Returns 302 instead of the default 404. Pass `null` to clear.",
+ "examples": [
+ "https://acme.com/404"
+ ]
+ },
+ "custom_robots_txt": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 4096
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Custom Robots Txt",
+ "description": "Override the default `User-agent: *\\nDisallow: /` body served at /robots.txt. Capped at 4096 chars. Pass `null` to clear. Note: responses to alias redirects always carry `X-Robots-Tag: noindex, nofollow, noarchive` regardless of this field - short URLs are pure redirects with no indexable content."
+ }
+ },
+ "type": "object",
+ "title": "UpdateCustomDomainRequest",
+ "description": "Body for ``PATCH /api/v1/custom-domains/{id}``.\n\nPartial update: callers send only the fields they want to change.\nField omitted = leave doc value as-is. Field explicitly ``null`` = clear\nthe stored value. The service distinguishes the two via ``model_fields_set``."
+ },
+ "UpdateProfileRequest": {
+ "properties": {
+ "user_name": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 255,
+ "minLength": 1
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "User Name",
+ "description": "New display name, or null to clear it",
+ "examples": [
+ "Jane Doe"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "user_name"
+ ],
+ "title": "UpdateProfileRequest",
+ "description": "Request body for PATCH /auth/me.\n\n``user_name`` is required but nullable: an explicit ``null`` clears\nthe display name (an accidental ``{}`` must not). Bounds mirror\nRegisterRequest - the two write paths for the same field."
+ },
+ "UpdateUrlRequest": {
+ "properties": {
+ "long_url": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 8192
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Long Url",
+ "description": "New destination URL. Must be a valid http:// or https:// URL.",
+ "examples": [
+ "https://example.com/updated/url"
+ ]
+ },
+ "alias": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 64,
+ "minLength": 1
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Alias",
+ "description": "New custom short code (alphanumeric 3-16 chars, or emoji-only 1-15 emoji). Pass `null` to keep existing. Must be unique and available.",
+ "examples": [
+ "newlink",
+ "🚀🔥"
+ ]
+ },
+ "password": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 128,
+ "minLength": 8
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Password",
+ "description": "New password. Pass `null` to remove password protection.",
+ "examples": [
+ "newPass@456"
+ ]
+ },
+ "block_bots": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Block Bots",
+ "description": "Block known bot user agents. Pass `null` to keep existing setting."
+ },
+ "max_clicks": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "minimum": 0.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Max Clicks",
+ "description": "New click limit. Pass `0` or `null` to remove the limit.",
+ "examples": [
+ 500
+ ]
+ },
+ "expire_after": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date-time"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Expire After",
+ "description": "Expiration time. ISO 8601 string (e.g. `2025-12-31T23:59:59Z`) or Unix epoch seconds (e.g. `1735689599`). Pass `null` to remove.",
+ "examples": [
+ "2025-12-31T23:59:59Z",
+ 1735689599
+ ]
+ },
+ "private_stats": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Private Stats",
+ "description": "Make statistics private (only owner can view). Pass `null` to keep existing."
+ },
+ "status": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "ACTIVE",
+ "INACTIVE"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Status",
+ "description": "URL status. ACTIVE enables redirects, INACTIVE disables them.",
+ "examples": [
+ "ACTIVE"
+ ]
+ },
+ "domain": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 253
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Domain",
+ "description": "Move the URL to a different domain namespace. Pass an owned ACTIVE custom-domain fqdn (e.g. `links.acme.com`) to move it there, or `null`/empty to move it back to the system default. Alias must be available on the target domain.",
+ "examples": [
+ "links.acme.com"
+ ]
+ },
+ "geo_rules": {
+ "anyOf": [
+ {
+ "additionalProperties": {
+ "type": "string"
},
- "type": "object",
- "required": [
- "id",
- "password_set"
- ],
- "title": "UrlListItem",
- "description": "A single URL entry inside UrlListResponse.items.\n\n``created_at`` and ``last_click`` are ISO 8601 strings (e.g. \"2024-01-01T00:00:00Z\").\n``expire_after`` is a Unix timestamp integer or null.\nThese formats match the existing endpoint exactly."
- },
- "UrlListResponse": {
- "properties": {
- "items": {
- "items": {
- "$ref": "#/components/schemas/UrlListItem"
- },
- "type": "array",
- "title": "Items"
- },
- "page": {
- "type": "integer",
- "title": "Page"
- },
- "pageSize": {
- "type": "integer",
- "title": "Pagesize"
- },
- "total": {
- "type": "integer",
- "title": "Total"
- },
- "hasNext": {
- "type": "boolean",
- "title": "Hasnext"
- },
- "sortBy": {
- "type": "string",
- "title": "Sortby"
- },
- "sortOrder": {
- "type": "string",
- "title": "Sortorder"
- }
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Geo Rules",
+ "description": "Per-country destination overrides: ISO 3166-1 alpha-2 country code → destination URL (at most 50 entries by default). The map replaces any existing rules in full. Pass `null` or `{}` to remove all rules; omit to keep existing rules unchanged.",
+ "examples": [
+ {
+ "IN": "https://example.in/",
+ "US": "https://example.com/us"
+ }
+ ]
+ },
+ "meta_tags": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MetaTagsRequest"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Custom social preview served to link-preview crawlers (WhatsApp, Discord, Slack, iMessage, …). The object replaces the whole setting; on PATCH pass null to remove. Requires a verified account with the feature enabled. Note: platforms cache previews for ~7-30 days - edits propagate slowly (the Facebook Sharing Debugger, LinkedIn Post Inspector, and Telegram's @WebpageBot force a refresh)."
+ }
+ },
+ "type": "object",
+ "title": "UpdateUrlRequest",
+ "description": "Request body for partially updating an existing shortened URL.\n\nAll fields are optional; only provided fields are updated.\nPass ``max_clicks=0`` or ``max_clicks=null`` to remove the limit.\nPass ``password=null`` (or omit) to remove password protection."
+ },
+ "UpdateUrlResponse": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "MongoDB ObjectId of the URL.",
+ "examples": [
+ "507f1f77bcf86cd799439011"
+ ]
+ },
+ "alias": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Alias",
+ "description": "Short code.",
+ "examples": [
+ "mylink"
+ ]
+ },
+ "long_url": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Long Url",
+ "description": "Destination URL.",
+ "examples": [
+ "https://example.com/long/url"
+ ]
+ },
+ "status": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/UrlStatus"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "URL status. Derived - reflects time and max-click expiry even before the stored flip is persisted.",
+ "examples": [
+ "ACTIVE"
+ ]
+ },
+ "password_set": {
+ "type": "boolean",
+ "title": "Password Set",
+ "description": "Whether the URL is password-protected."
+ },
+ "max_clicks": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Max Clicks",
+ "description": "Click limit, or null if unlimited.",
+ "examples": [
+ 100
+ ]
+ },
+ "expire_after": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Expire After",
+ "description": "Expiration as Unix timestamp, or null.",
+ "examples": [
+ 1735689599
+ ]
+ },
+ "block_bots": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Block Bots",
+ "description": "Whether bot blocking is enabled."
+ },
+ "private_stats": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Private Stats",
+ "description": "Whether statistics are private."
+ },
+ "domain": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Domain",
+ "description": "Domain fqdn the URL is served on. Null for the system default.",
+ "examples": [
+ "links.acme.com"
+ ]
+ },
+ "geo_rules": {
+ "anyOf": [
+ {
+ "additionalProperties": {
+ "type": "string"
},
- "type": "object",
- "required": [
- "items",
- "page",
- "pageSize",
- "total",
- "hasNext",
- "sortBy",
- "sortOrder"
- ],
- "title": "UrlListResponse",
- "description": "Response body for GET /api/v1/urls.\n\nUses camelCase field names to match the existing Flask endpoint exactly.\nField names are camelCase here (not snake_case + alias) because this is a\nresponse-only model \u2014 we build it explicitly in the route handler."
- },
- "UrlResponse": {
- "properties": {
- "alias": {
- "type": "string",
- "title": "Alias",
- "description": "Short code for the URL.",
- "examples": [
- "mylink"
- ]
- },
- "short_url": {
- "type": "string",
- "title": "Short Url",
- "description": "Full shortened URL ready for sharing.",
- "examples": [
- "https://spoo.me/mylink"
- ]
- },
- "long_url": {
- "type": "string",
- "title": "Long Url",
- "description": "Original destination URL.",
- "examples": [
- "https://example.com/long/url"
- ]
- },
- "owner_id": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Owner Id",
- "description": "User ID if authenticated, null for anonymous URLs.",
- "examples": [
- "507f1f77bcf86cd799439011"
- ]
- },
- "created_at": {
- "type": "integer",
- "title": "Created At",
- "description": "Creation time as Unix timestamp.",
- "examples": [
- 1704067200
- ]
- },
- "status": {
- "type": "string",
- "title": "Status",
- "description": "URL status.",
- "examples": [
- "ACTIVE"
- ]
- },
- "private_stats": {
- "anyOf": [
- {
- "type": "boolean"
- },
- {
- "type": "null"
- }
- ],
- "title": "Private Stats",
- "description": "Whether statistics are private (owner-only)."
- }
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Geo Rules",
+ "description": "Per-country destination overrides (ISO alpha-2 code → URL), or null.",
+ "examples": [
+ {
+ "IN": "https://example.in/"
+ }
+ ]
+ },
+ "updated_at": {
+ "type": "integer",
+ "title": "Updated At",
+ "description": "Last update time as Unix timestamp.",
+ "examples": [
+ 1704067200
+ ]
+ },
+ "meta_tags": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MetaTagsResponse"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Custom social preview, if configured."
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "password_set",
+ "updated_at"
+ ],
+ "title": "UpdateUrlResponse",
+ "description": "Response body after a successful URL update (PATCH /api/v1/urls/{url_id})."
+ },
+ "UpdateUrlStatusRequest": {
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "ACTIVE",
+ "INACTIVE"
+ ],
+ "title": "Status",
+ "description": "New status for the URL. `ACTIVE` enables redirects, `INACTIVE` disables them.",
+ "examples": [
+ "ACTIVE"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "status"
+ ],
+ "title": "UpdateUrlStatusRequest",
+ "description": "Request body for updating only the status of a shortened URL."
+ },
+ "UpdateWebhookEndpointRequest": {
+ "properties": {
+ "url": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 2048
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Url"
+ },
+ "events": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
},
- "type": "object",
- "required": [
- "alias",
- "short_url",
- "long_url",
- "created_at",
- "status"
- ],
- "title": "UrlResponse",
- "description": "Response body for a newly created shortened URL (POST /api/v1/shorten).\n\n``created_at`` is a Unix timestamp integer \u2014 matching the existing endpoint."
- },
- "UserPfp": {
- "properties": {
- "url": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Url",
- "description": "Profile picture URL",
- "examples": [
- "https://lh3.googleusercontent.com/a/photo"
- ]
- },
- "source": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Source",
- "description": "Source of the profile picture",
- "examples": [
- "google"
- ]
- }
+ "type": "array",
+ "maxItems": 32,
+ "minItems": 1
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Events"
+ },
+ "description": {
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 256
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Description"
+ },
+ "scope_links": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
},
- "type": "object",
- "title": "UserPfp",
- "description": "Profile picture info returned inside UserProfileResponse."
+ "type": "array",
+ "maxItems": 256
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Scope Links"
+ },
+ "flavor": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/WebhookFlavor"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "status": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/WebhookStatus"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "`active` or `paused`. `disabled` is system-set."
+ }
+ },
+ "type": "object",
+ "title": "UpdateWebhookEndpointRequest",
+ "description": "All fields optional; only provided fields change. `scope_links: null`\nis 'all links' - to keep an existing scope, omit the field."
+ },
+ "UploadProfilePictureRequest": {
+ "properties": {
+ "image": {
+ "type": "string",
+ "minLength": 1,
+ "title": "Image",
+ "description": "base64 data URI (image/png, image/jpeg, image/webp)"
+ }
+ },
+ "type": "object",
+ "required": [
+ "image"
+ ],
+ "title": "UploadProfilePictureRequest"
+ },
+ "UrlListItem": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id"
+ },
+ "alias": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Alias"
+ },
+ "long_url": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Long Url"
+ },
+ "status": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/UrlStatus"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "created_at": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date-time"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Created At"
+ },
+ "expire_after": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Expire After"
+ },
+ "max_clicks": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Max Clicks"
+ },
+ "private_stats": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Private Stats"
+ },
+ "block_bots": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Block Bots"
+ },
+ "password_set": {
+ "type": "boolean",
+ "title": "Password Set"
+ },
+ "total_clicks": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Total Clicks"
+ },
+ "last_click": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date-time"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Click"
+ },
+ "domain": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Domain"
+ },
+ "geo_rules": {
+ "anyOf": [
+ {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Geo Rules"
+ },
+ "meta_tags": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MetaTagsResponse"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "password_set"
+ ],
+ "title": "UrlListItem",
+ "description": "A single URL entry inside UrlListResponse.items.\n\n``created_at`` and ``last_click`` are ISO 8601 strings (e.g. \"2024-01-01T00:00:00Z\").\n``expire_after`` is a Unix timestamp integer or null.\nThese formats match the existing endpoint exactly.\n``status`` is derived - it reflects time and max-click expiry even\nbefore the stored flip is persisted."
+ },
+ "UrlListResponse": {
+ "properties": {
+ "items": {
+ "items": {
+ "$ref": "#/components/schemas/UrlListItem"
},
- "UserProfileResponse": {
- "properties": {
- "id": {
- "type": "string",
- "title": "Id",
- "description": "User ID",
- "examples": [
- "507f1f77bcf86cd799439011"
- ]
- },
- "email": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Email",
- "description": "User's email address",
- "examples": [
- "user@example.com"
- ]
- },
- "email_verified": {
- "type": "boolean",
- "title": "Email Verified",
- "description": "Whether the email address has been verified"
- },
- "user_name": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "User Name",
- "description": "Display name",
- "examples": [
- "Jane Doe"
- ]
- },
- "plan": {
- "type": "string",
- "title": "Plan",
- "description": "Subscription plan",
- "examples": [
- "free"
- ]
- },
- "password_set": {
- "type": "boolean",
- "title": "Password Set",
- "description": "Whether the user has set a password"
- },
- "auth_providers": {
- "items": {
- "$ref": "#/components/schemas/AuthProviderInfo"
- },
- "type": "array",
- "title": "Auth Providers",
- "description": "Linked OAuth providers"
- },
- "pfp": {
- "anyOf": [
- {
- "$ref": "#/components/schemas/UserPfp"
- },
- {
- "type": "null"
- }
- ],
- "description": "Profile picture (absent when not set)"
- }
+ "type": "array",
+ "title": "Items"
+ },
+ "page": {
+ "type": "integer",
+ "title": "Page"
+ },
+ "pageSize": {
+ "type": "integer",
+ "title": "Pagesize"
+ },
+ "total": {
+ "type": "integer",
+ "title": "Total"
+ },
+ "hasNext": {
+ "type": "boolean",
+ "title": "Hasnext"
+ },
+ "sortBy": {
+ "type": "string",
+ "title": "Sortby"
+ },
+ "sortOrder": {
+ "type": "string",
+ "title": "Sortorder"
+ }
+ },
+ "type": "object",
+ "required": [
+ "items",
+ "page",
+ "pageSize",
+ "total",
+ "hasNext",
+ "sortBy",
+ "sortOrder"
+ ],
+ "title": "UrlListResponse",
+ "description": "Response body for GET /api/v1/urls.\n\nUses camelCase field names to match the existing Flask endpoint exactly.\nField names are camelCase here (not snake_case + alias) because this is a\nresponse-only model - we build it explicitly in the route handler."
+ },
+ "UrlResponse": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "MongoDB ObjectId of the URL - the identifier the management endpoints (`/urls/{url_id}`) address it by.",
+ "examples": [
+ "507f1f77bcf86cd799439011"
+ ]
+ },
+ "alias": {
+ "type": "string",
+ "title": "Alias",
+ "description": "Short code for the URL.",
+ "examples": [
+ "mylink"
+ ]
+ },
+ "short_url": {
+ "type": "string",
+ "title": "Short Url",
+ "description": "Full shortened URL ready for sharing. Emoji aliases appear unencoded (clients/browsers percent-encode on use).",
+ "examples": [
+ "https://spoo.me/mylink",
+ "https://spoo.me/🚀🔥"
+ ]
+ },
+ "long_url": {
+ "type": "string",
+ "title": "Long Url",
+ "description": "Original destination URL.",
+ "examples": [
+ "https://example.com/long/url"
+ ]
+ },
+ "owner_id": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Owner Id",
+ "description": "User ID if authenticated, null for anonymous URLs.",
+ "examples": [
+ "507f1f77bcf86cd799439011"
+ ]
+ },
+ "created_at": {
+ "type": "integer",
+ "title": "Created At",
+ "description": "Creation time as Unix timestamp.",
+ "examples": [
+ 1704067200
+ ]
+ },
+ "status": {
+ "$ref": "#/components/schemas/UrlStatus",
+ "description": "URL status. Derived - reflects time and max-click expiry even before the stored flip is persisted.",
+ "examples": [
+ "ACTIVE"
+ ]
+ },
+ "private_stats": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Private Stats",
+ "description": "Whether statistics are private (owner-only)."
+ },
+ "geo_rules": {
+ "anyOf": [
+ {
+ "additionalProperties": {
+ "type": "string"
},
- "type": "object",
- "required": [
- "id",
- "email_verified",
- "plan",
- "password_set",
- "auth_providers"
- ],
- "title": "UserProfileResponse",
- "description": "User profile shape \u2014 used in login/register/me responses."
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Geo Rules",
+ "description": "Per-country destination overrides (ISO alpha-2 code → URL), or null.",
+ "examples": [
+ {
+ "IN": "https://example.in/"
+ }
+ ]
+ },
+ "meta_tags": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MetaTagsResponse"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Custom social preview, if configured."
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "alias",
+ "short_url",
+ "long_url",
+ "created_at",
+ "status"
+ ],
+ "title": "UrlResponse",
+ "description": "Response body for a newly created shortened URL (POST /api/v1/shorten).\n\n``created_at`` is a Unix timestamp integer - matching the existing endpoint."
+ },
+ "UrlStatus": {
+ "type": "string",
+ "enum": [
+ "ACTIVE",
+ "INACTIVE",
+ "EXPIRED",
+ "BLOCKED"
+ ],
+ "title": "UrlStatus",
+ "description": "Status values for v2 URLs."
+ },
+ "UserPfp": {
+ "properties": {
+ "url": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Url",
+ "description": "Profile picture URL",
+ "examples": [
+ "https://lh3.googleusercontent.com/a/photo"
+ ]
+ },
+ "source": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/OAuthProvider"
+ },
+ {
+ "type": "string",
+ "const": "upload"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Source",
+ "description": "Source of the profile picture - an OAuth provider or `upload`",
+ "examples": [
+ "google"
+ ]
+ }
+ },
+ "type": "object",
+ "title": "UserPfp",
+ "description": "Profile picture info returned inside UserProfileResponse."
+ },
+ "UserProfileResponse": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "User ID",
+ "examples": [
+ "507f1f77bcf86cd799439011"
+ ]
+ },
+ "email": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Email",
+ "description": "User's email address",
+ "examples": [
+ "user@example.com"
+ ]
+ },
+ "email_verified": {
+ "type": "boolean",
+ "title": "Email Verified",
+ "description": "Whether the email address has been verified"
+ },
+ "user_name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "User Name",
+ "description": "Display name",
+ "examples": [
+ "Jane Doe"
+ ]
+ },
+ "plan": {
+ "type": "string",
+ "title": "Plan",
+ "description": "Subscription plan",
+ "examples": [
+ "free"
+ ]
+ },
+ "password_set": {
+ "type": "boolean",
+ "title": "Password Set",
+ "description": "Whether the user has set a password"
+ },
+ "onboarded_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Onboarded At",
+ "description": "When the user completed onboarding (null = never)"
+ },
+ "auth_providers": {
+ "items": {
+ "$ref": "#/components/schemas/AuthProviderInfo"
},
- "ValidationError": {
- "properties": {
- "loc": {
- "items": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "integer"
- }
- ]
- },
- "type": "array",
- "title": "Location"
- },
- "msg": {
- "type": "string",
- "title": "Message"
- },
- "type": {
- "type": "string",
- "title": "Error Type"
- },
- "input": {
- "title": "Input"
- },
- "ctx": {
- "type": "object",
- "title": "Context"
- }
+ "type": "array",
+ "title": "Auth Providers",
+ "description": "Linked OAuth providers"
+ },
+ "pfp": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/UserPfp"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Profile picture (absent when not set)"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "email_verified",
+ "plan",
+ "password_set",
+ "auth_providers"
+ ],
+ "title": "UserProfileResponse",
+ "description": "User profile shape - used in login/register/me responses."
+ },
+ "ValidationError": {
+ "properties": {
+ "loc": {
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
},
- "type": "object",
- "required": [
- "loc",
- "msg",
- "type"
- ],
- "title": "ValidationError"
+ {
+ "type": "integer"
+ }
+ ]
+ },
+ "type": "array",
+ "title": "Location"
+ },
+ "msg": {
+ "type": "string",
+ "title": "Message"
+ },
+ "type": {
+ "type": "string",
+ "title": "Error Type"
+ },
+ "input": {
+ "title": "Input"
+ },
+ "ctx": {
+ "type": "object",
+ "title": "Context"
+ }
+ },
+ "type": "object",
+ "required": [
+ "loc",
+ "msg",
+ "type"
+ ],
+ "title": "ValidationError"
+ },
+ "VerificationMethod": {
+ "type": "string",
+ "enum": [
+ "cname",
+ "a_record",
+ "txt_challenge",
+ "system",
+ "cf_delegated_dcv",
+ "cf_http_dcv"
+ ],
+ "title": "VerificationMethod",
+ "description": "How ownership of the fqdn is proven before activation."
+ },
+ "VerifyEmailRequest": {
+ "properties": {
+ "code": {
+ "type": "string",
+ "pattern": "^[0-9]{6}$",
+ "title": "Code",
+ "description": "6-digit OTP from verification email",
+ "examples": [
+ "123456"
+ ]
+ }
+ },
+ "type": "object",
+ "required": [
+ "code"
+ ],
+ "title": "VerifyEmailRequest",
+ "description": "Request body for POST /auth/verify-email.\n\n``code`` is the 6-digit OTP sent to the user's email address."
+ },
+ "VerifyEmailResponse": {
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "title": "Success",
+ "description": "Whether verification succeeded"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "Human-readable status message",
+ "examples": [
+ "email verified successfully"
+ ]
+ },
+ "email_verified": {
+ "type": "boolean",
+ "title": "Email Verified",
+ "description": "Updated email verification status (always true on success)"
+ }
+ },
+ "type": "object",
+ "required": [
+ "success",
+ "message",
+ "email_verified"
+ ],
+ "title": "VerifyEmailResponse",
+ "description": "Response body for POST /auth/verify-email (200)."
+ },
+ "WebhookDeliveryResponse": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id"
+ },
+ "webhook_id": {
+ "type": "string",
+ "title": "Webhook Id"
+ },
+ "event_type": {
+ "type": "string",
+ "title": "Event Type"
+ },
+ "is_test": {
+ "type": "boolean",
+ "title": "Is Test"
+ },
+ "status": {
+ "$ref": "#/components/schemas/DeliveryStatus"
+ },
+ "attempt_count": {
+ "type": "integer",
+ "title": "Attempt Count"
+ },
+ "attempts": {
+ "items": {
+ "$ref": "#/components/schemas/DeliveryAttemptResponse"
+ },
+ "type": "array",
+ "title": "Attempts"
+ },
+ "next_attempt_at": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Next Attempt At"
+ },
+ "rendered_body": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Rendered Body"
+ },
+ "created_at": {
+ "type": "integer",
+ "title": "Created At"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "webhook_id",
+ "event_type",
+ "is_test",
+ "status",
+ "attempt_count",
+ "attempts",
+ "created_at"
+ ],
+ "title": "WebhookDeliveryResponse"
+ },
+ "WebhookEndpointCreatedResponse": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id"
+ },
+ "url": {
+ "type": "string",
+ "title": "Url"
+ },
+ "description": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Description"
+ },
+ "events": {
+ "items": {
+ "type": "string"
},
- "VerifyEmailRequest": {
- "properties": {
- "code": {
- "type": "string",
- "title": "Code",
- "description": "6-digit OTP from verification email",
- "examples": [
- "123456"
- ]
- }
+ "type": "array",
+ "title": "Events"
+ },
+ "scope_links": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
},
- "type": "object",
- "required": [
- "code"
- ],
- "title": "VerifyEmailRequest",
- "description": "Request body for POST /auth/verify-email.\n\n``code`` is the 6-digit OTP sent to the user's email address."
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Scope Links",
+ "description": "Null means all links, including future ones."
+ },
+ "flavor": {
+ "$ref": "#/components/schemas/WebhookFlavor"
+ },
+ "status": {
+ "$ref": "#/components/schemas/WebhookStatus"
+ },
+ "disabled_reason": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Disabled Reason"
+ },
+ "signing_secret_prefix": {
+ "type": "string",
+ "title": "Signing Secret Prefix"
+ },
+ "consecutive_failures": {
+ "type": "integer",
+ "title": "Consecutive Failures"
+ },
+ "total_deliveries": {
+ "type": "integer",
+ "title": "Total Deliveries"
+ },
+ "total_successes": {
+ "type": "integer",
+ "title": "Total Successes"
+ },
+ "last_delivery_at": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Delivery At"
+ },
+ "last_success_at": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Success At"
+ },
+ "last_failure_reason": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Failure Reason"
+ },
+ "created_at": {
+ "type": "integer",
+ "title": "Created At"
+ },
+ "signing_secret": {
+ "type": "string",
+ "title": "Signing Secret",
+ "description": "The full signing secret - shown ONCE, never retrievable again. Store it; you need it to verify webhook signatures."
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "url",
+ "events",
+ "flavor",
+ "status",
+ "signing_secret_prefix",
+ "consecutive_failures",
+ "total_deliveries",
+ "total_successes",
+ "created_at",
+ "signing_secret"
+ ],
+ "title": "WebhookEndpointCreatedResponse"
+ },
+ "WebhookEndpointResponse": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id"
+ },
+ "url": {
+ "type": "string",
+ "title": "Url"
+ },
+ "description": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Description"
+ },
+ "events": {
+ "items": {
+ "type": "string"
},
- "VerifyEmailResponse": {
- "properties": {
- "success": {
- "type": "boolean",
- "title": "Success",
- "description": "Whether verification succeeded"
- },
- "message": {
- "type": "string",
- "title": "Message",
- "description": "Human-readable status message",
- "examples": [
- "email verified successfully"
- ]
- },
- "email_verified": {
- "type": "boolean",
- "title": "Email Verified",
- "description": "Updated email verification status (always true on success)"
- }
+ "type": "array",
+ "title": "Events"
+ },
+ "scope_links": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
},
- "type": "object",
- "required": [
- "success",
- "message",
- "email_verified"
- ],
- "title": "VerifyEmailResponse",
- "description": "Response body for POST /auth/verify-email (200)."
- }
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Scope Links",
+ "description": "Null means all links, including future ones."
+ },
+ "flavor": {
+ "$ref": "#/components/schemas/WebhookFlavor"
+ },
+ "status": {
+ "$ref": "#/components/schemas/WebhookStatus"
+ },
+ "disabled_reason": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Disabled Reason"
+ },
+ "signing_secret_prefix": {
+ "type": "string",
+ "title": "Signing Secret Prefix"
+ },
+ "consecutive_failures": {
+ "type": "integer",
+ "title": "Consecutive Failures"
+ },
+ "total_deliveries": {
+ "type": "integer",
+ "title": "Total Deliveries"
+ },
+ "total_successes": {
+ "type": "integer",
+ "title": "Total Successes"
+ },
+ "last_delivery_at": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Delivery At"
+ },
+ "last_success_at": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Success At"
+ },
+ "last_failure_reason": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Failure Reason"
+ },
+ "created_at": {
+ "type": "integer",
+ "title": "Created At"
+ }
},
- "securitySchemes": {
- "ApiKeyAuth": {
- "type": "http",
- "scheme": "bearer",
- "bearerFormat": "spoo_",
- "description": "API key authentication. Pass your key as: `Bearer spoo_`"
+ "type": "object",
+ "required": [
+ "id",
+ "url",
+ "events",
+ "flavor",
+ "status",
+ "signing_secret_prefix",
+ "consecutive_failures",
+ "total_deliveries",
+ "total_successes",
+ "created_at"
+ ],
+ "title": "WebhookEndpointResponse"
+ },
+ "WebhookEndpointsListResponse": {
+ "properties": {
+ "endpoints": {
+ "items": {
+ "$ref": "#/components/schemas/WebhookEndpointResponse"
},
- "JWTAuth": {
- "type": "http",
- "scheme": "bearer",
- "bearerFormat": "JWT",
- "description": "JWT access token from /auth/login. Pass as: `Bearer `"
- }
- }
- },
- "tags": [
- {
- "name": "URL Shortening",
- "description": "Create new shortened URLs"
- },
- {
- "name": "Link Management",
- "description": "List, update, and delete your shortened URLs"
- },
- {
- "name": "Statistics",
- "description": "Click analytics and data export"
- },
- {
- "name": "API Keys",
- "description": "Create and manage API keys for programmatic access"
- },
- {
- "name": "Authentication",
- "description": "Login, register, password management, and email verification"
- },
- {
- "name": "OAuth",
- "description": "OAuth provider login, linking, and unlinking"
- },
- {
- "name": "System",
- "description": "Health checks and server metrics"
- }
- ],
- "servers": [
- {
- "url": "https://spoo.me",
- "description": "Production"
- }
- ],
- "security": [
- {
- "ApiKeyAuth": []
+ "type": "array",
+ "title": "Endpoints"
+ }
},
- {
- "JWTAuth": []
- }
- ]
+ "type": "object",
+ "required": [
+ "endpoints"
+ ],
+ "title": "WebhookEndpointsListResponse"
+ },
+ "WebhookFlavor": {
+ "type": "string",
+ "enum": [
+ "raw",
+ "discord",
+ "slack"
+ ],
+ "title": "WebhookFlavor",
+ "description": "Presentation of the payload - never a second schema. ``raw`` is the\nversioned contract; other flavors are lossy renderings of it."
+ },
+ "WebhookSecretResponse": {
+ "properties": {
+ "signing_secret": {
+ "type": "string",
+ "title": "Signing Secret",
+ "description": "The full signing secret, revealed to the endpoint owner."
+ }
+ },
+ "type": "object",
+ "required": [
+ "signing_secret"
+ ],
+ "title": "WebhookSecretResponse"
+ },
+ "WebhookStatus": {
+ "type": "string",
+ "enum": [
+ "active",
+ "paused",
+ "disabled"
+ ],
+ "title": "WebhookStatus"
+ }
+ },
+ "securitySchemes": {
+ "ApiKeyAuth": {
+ "type": "http",
+ "scheme": "bearer",
+ "bearerFormat": "spoo_",
+ "description": "API key authentication. Pass your key as: `Bearer spoo_`"
+ },
+ "JWTAuth": {
+ "type": "http",
+ "scheme": "bearer",
+ "bearerFormat": "JWT",
+ "description": "JWT access token from /auth/login. Pass as: `Bearer `"
+ }
+ }
+ },
+ "tags": [
+ {
+ "name": "URL Shortening",
+ "description": "Create new shortened URLs"
+ },
+ {
+ "name": "Me",
+ "description": "Per-user preferences and dashboard layouts"
+ },
+ {
+ "name": "Link Management",
+ "description": "List, update, and delete your shortened URLs"
+ },
+ {
+ "name": "Statistics",
+ "description": "Click analytics and data export"
+ },
+ {
+ "name": "API Keys",
+ "description": "Create and manage API keys for programmatic access"
+ },
+ {
+ "name": "Authentication",
+ "description": "Login, register, password management, and email verification"
+ },
+ {
+ "name": "OAuth",
+ "description": "OAuth provider login, linking, and unlinking"
+ },
+ {
+ "name": "Reports & Contact",
+ "description": "Report abusive URLs and contact the site operators"
+ },
+ {
+ "name": "System",
+ "description": "Health checks and server metrics"
+ }
+ ],
+ "servers": [
+ {
+ "url": "https://spoo.me",
+ "description": "Production"
+ }
+ ],
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "JWTAuth": []
+ }
+ ]
}
\ No newline at end of file
diff --git a/quickstart.mdx b/quickstart.mdx
index c9d923d..82f1299 100644
--- a/quickstart.mdx
+++ b/quickstart.mdx
@@ -10,6 +10,19 @@ Get up and running with the Spoo.me v1 API in just a few minutes. This guide wil
This guide uses the **v1 API without authentication** (anonymous mode). For higher rate limits and advanced features, check out [API Keys](/api-keys).
+
+If you authenticate, your account's email must be **verified** to shorten URLs. Anonymous requests are not affected, but a signed-in user or API key belonging to an unverified account gets a 403 until the email is verified.
+
+
+## Prefer the dashboard?
+
+Everything in this guide can also be done visually. Hit **New link** in the [dashboard](https://spoo.me/dashboard), paste a destination, and you have a short link with analytics attached. The [creating links guide](/links/creating-links) walks through the composer.
+
+
+
+
+
+
## Step 1: Make Your First API Call
No registration or API keys required! You can start using the v1 API immediately with anonymous access.
diff --git a/rate-limits.mdx b/rate-limits.mdx
index 3ae71f7..2825cc7 100644
--- a/rate-limits.mdx
+++ b/rate-limits.mdx
@@ -4,15 +4,21 @@ description: "Understanding Spoo.me API rate limits and best practices"
icon: "clock"
---
-Spoo.me API implements rate limiting to ensure fair usage and maintain service quality for all users. Rate limits vary based on authentication method and API version.
+Spoo.me API implements rate limiting to ensure fair usage and maintain service quality for all users. Rate limits vary based on authentication method and endpoint.
+
+## How limits are counted
+
+Rate limits are tracked per API key, per JWT session, or per IP address, in that order. If you authenticate with an API key, your limit bucket follows the key. If you use a JWT (header or session cookie), it follows the token. Anonymous requests are bucketed by client IP.
+
+Unless an endpoint lists its own limits below, it falls under the global default of **10 requests/minute, 100/hour and 500/day**.
## API v1 Rate Limits (Recommended)
-The new v1 API offers **significantly higher rate limits** with authentication:
+The core v1 endpoints (shorten, stats, list URLs) use two tiers:
### With Authentication (JWT or API Key)
-
+
**5,000 requests**
-
- All v1 endpoints
-
### Without Authentication (Anonymous)
-
+
**1,000 requests**
-
- Limited endpoints
-
- **Get 3x more requests** by using [API keys](/api-keys) or JWT authentication! Anonymous users are limited to 20 requests/minute vs 60 requests/minute for authenticated users.
+ Authenticating with an [API key](/api-keys) or JWT gives you 3x the per-minute limit and 5x the daily limit on the core endpoints, plus access to endpoints that anonymous users cannot call at all. See [Authentication](/api-reference/authentication) for how the three methods work.
-## API v0 Rate Limits (Legacy)
+## Rate Limits by Endpoint
-The legacy v0 API has lower rate limits:
+### URL Shortening
-
-
- **10 short URLs** per IP
-
-
- **100 short URLs** per IP
-
-
- **500 short URLs** per IP
-
-
+| Endpoint | Authenticated | Anonymous |
+|----------|--------------|-----------|
+| `POST /api/v1/shorten` | 60/min, 5,000/day | 20/min, 1,000/day |
+| `GET /api/v1/shorten/check-alias` | 180/min, 10,000/day | 60/min, 2,000/day |
+| `GET /api/v1/emoji-set` | 60/min, 2,000/day | 60/min, 2,000/day |
-
- The v0 API is **legacy** and does not support authentication. We recommend migrating to the v1 API for better rate limits and features.
-
+
+`check-alias` has a higher tier because interfaces typically call it on every keystroke. `emoji-set` returns the accepted emoji catalog and serves the same response to everyone, so both tiers match.
+
-## Rate Limits by Endpoint
+### Link Management
-### URL Shortening
+| Endpoint | Authenticated | Anonymous |
+|----------|--------------|-----------|
+| `GET /api/v1/urls` | 60/min, 5,000/day | Not allowed |
+| `GET /api/v1/urls/{url_id}` | 60/min, 5,000/day | Not allowed |
+| `GET /api/v1/urls/{domain}/{alias}` | 60/min, 5,000/day | Not allowed |
+| `PATCH /api/v1/urls/{url_id}` | 120/min, 2,000/day | Not allowed |
+| `PATCH /api/v1/urls/{url_id}/status` | 120/min, 2,000/day | Not allowed |
+| `DELETE /api/v1/urls/{url_id}` | 60/min, 1,000/day | Not allowed |
+| `DELETE /api/v1/urls` (bulk, by domain) | 5/min, 50/day | Not allowed |
+
+### Bulk Operations
+
+Bulk endpoints accept up to 100 link ids per request, and limits count **requests, not items**. One call updating 100 links spends one unit of your budget.
+
+| Endpoint | Authenticated | Anonymous |
+|----------|--------------|-----------|
+| `POST /api/v1/urls/bulk/status` | 60/min, 200/day | Not allowed |
+| `POST /api/v1/urls/bulk/expiry` | 60/min, 200/day | Not allowed |
+| `POST /api/v1/urls/bulk/domain` | 60/min, 200/day | Not allowed |
+| `POST /api/v1/urls/bulk/delete` | 30/min, 100/day | Not allowed |
+
+### Statistics & Export
-#### v1 API Endpoints
-| Endpoint | Authenticated | Anonymous | Notes |
-|----------|--------------|-----------|-------|
-| `POST /api/v1/shorten` | 60/min, 5000/day | 20/min, 1000/day | v1 API |
+| Endpoint | Authenticated | Anonymous |
+|----------|--------------|-----------|
+| `GET /api/v1/stats` | 60/min, 5,000/day | 20/min, 1,000/day |
+| `GET /api/v1/export` | 30/min, 1,000/day | 10/min, 200/day |
-#### v0 API Endpoints (Legacy)
+### Public Stats & Preview
+
+Per-link stats pages and link previews work without an account, so they carry their own budgets.
+
+| Endpoint | Authenticated | Anonymous |
+|----------|--------------|-----------|
+| `GET /api/v1/public/stats/{short_code}` | 60/min, 2,000/day | 20/min, 500/day |
+| `POST /api/v1/public/stats/{short_code}` (password unlock) | 60/min, 2,000/day | 20/min, 500/day |
+| `GET /api/v1/public/preview/{short_code}` | 30/min, 2,000/day | 30/min, 2,000/day |
+| `POST /{short_code}/password` (password page) | 10/min, 30/hour | 10/min, 30/hour |
+
+### Metadata
+
+| Endpoint | Authenticated | Anonymous |
+|----------|--------------|-----------|
+| `GET /api/v1/metadata` | 20/min, 500/day | Not allowed |
+
+
+Each metadata call triggers an outbound fetch of the destination page from Spoo.me servers, so this budget is deliberately tight.
+
+
+### Reports & Contact
+
+| Endpoint | Authenticated | Anonymous |
+|----------|--------------|-----------|
+| `POST /api/v1/reports` | 30/min, 500/day | 5/min, 40/day |
+| `POST /api/v1/contact` | 5/min, 20/hour, 50/day | 5/min, 20/hour, 50/day |
-The following endpoints are part of the legacy v0 API. They do not support authentication and have lower rate limits.
+Report limits count submissions, not reported items. Authenticated reporters may batch up to 100 items per request; anonymous reporters are captcha-gated and capped at 25 items per request.
-| Endpoint | Authenticated | Anonymous | Notes |
-|----------|--------------|-----------|-------|
-| `POST /` | - | 10/min, 100/hr, 500/day | v0 API (legacy) |
-| `POST /emoji` | - | 10/min, 100/hr, 500/day | v0 API (legacy) |
+### API Keys
+
+| Endpoint | Limit |
+|----------|-------|
+| `POST /api/v1/keys` | 5/hour |
+| `GET /api/v1/keys` | 60/min |
+| `DELETE /api/v1/keys/{key_id}` | 30/min |
+
+### Custom Domains
+
+| Endpoint | Limit |
+|----------|-------|
+| `POST /api/v1/custom-domains` | 10/hour |
+| `POST /api/v1/custom-domains/{id}/verify` | 10/min |
+| `GET /api/v1/custom-domains` (list and get) | 60/min |
+| `PATCH /api/v1/custom-domains/{id}` | 30/min |
+| `DELETE /api/v1/custom-domains/{id}` | 10/min |
+| `DELETE /api/v1/custom-domains/{id}/permanent` | 10/min |
+
+### Webhooks
+
+| Endpoint | Limit |
+|----------|-------|
+| `GET /api/v1/webhooks/event-types` (public catalog) | 60/min |
+| `POST /api/v1/webhooks` | 10/hour |
+| `GET /api/v1/webhooks` (list, get, secret, deliveries) | 60/min |
+| `PATCH /api/v1/webhooks/{id}` | 30/min |
+| `DELETE /api/v1/webhooks/{id}` | 30/min |
+| `POST /api/v1/webhooks/{id}/test` | 10/min |
+| `POST /api/v1/webhooks/{id}/deliveries/{delivery_id}/retry` | 30/min |
-### URL Management
+
+Webhook creation and test sends are tight because each one authorizes or triggers real outbound requests from Spoo.me servers.
+
+
+### Account
-| Endpoint | Authenticated | Anonymous | Notes |
-|----------|--------------|-----------|-------|
-| `GET /api/v1/urls` | 60/min, 5000/day | Not allowed | Requires auth |
-| `PATCH /api/v1/urls/{id}` | 60/min, 5000/day | Not allowed | Requires auth |
-| `PATCH /api/v1/urls/{id}/status` | 60/min, 5000/day | Not allowed | Requires auth |
-| `DELETE /api/v1/urls/{id}` | 60/min, 5000/day | Not allowed | Requires auth |
+These endpoints require an interactive dashboard session.
-### Analytics
+| Endpoint | Limit |
+|----------|-------|
+| `GET /api/v1/me/features` | 60/min |
+| `GET /api/v1/me/layouts/{page}` | 120/min |
+| `PUT /api/v1/me/layouts/{page}` | 60/min |
+| `DELETE /api/v1/me/layouts/{page}` | 30/min |
+| `GET /api/v1/me/profile-pictures` | 60/min |
+| `POST /api/v1/me/profile-pictures` | 10/min |
+| `POST /api/v1/me/profile-pictures/upload` | 5/min |
+| `DELETE /api/v1/me/profile-pictures` | 10/min |
+| `GET /api/v1/apps` | 60/min |
-| Endpoint | Authenticated | Anonymous | Notes |
-|----------|--------------|-----------|-------|
-| `GET /api/v1/stats` | 60/min, 5000/day | 20/min, 1000/day | Public URLs only for anon |
-| `POST /stats/{code}` | - | - | v0 API (legacy) |
-| `POST /export/{code}/{format}` | - | - | v0 API (legacy) |
+### API v0 Endpoints (Legacy)
+
+
+The following endpoints are part of the legacy v0 API. They do not support authentication, so all limits apply per IP.
+
+
+| Endpoint | Limit |
+|----------|-------|
+| `POST /` | 100/min |
+| `POST /emoji` | 100/min |
+| `POST /stats/{code}` | 20/min, 1,000/day |
+| `POST /export/{code}/{format}` | 10/min, 200/day |
## Authentication & Rate Limits
@@ -141,69 +204,61 @@ How you authenticate directly affects your rate limits:
- **Rate Limits**: 60/min, 5000/day
-
+ **Rate Limits**: 60/min, 5,000/day on core endpoints
+
**Benefits**:
- - 3x higher rate limits vs anonymous
+ - Higher rate limits vs anonymous
- Access to URL management endpoints
- Private stats support
- Long-lived tokens for automation
-
+
**How to use**: [Create an API key](/api-keys) and include it in the `Authorization` header:
```bash
Authorization: Bearer spoo_YOUR_API_KEY
```
-
+
- **Rate Limits**: 60/min, 5000/day
-
+ **Rate Limits**: 60/min, 5,000/day on core endpoints
+
**Benefits**:
- Same rate limits as API keys
- Full dashboard access
- Can create/manage API keys
- Manage URLs via web UI
-
+
**How to use**: Obtain JWT via OAuth/login and include in header:
```bash
Authorization: Bearer YOUR_JWT_TOKEN
```
-
+
- **Rate Limits**: 20/min, 1000/day
-
+ **Rate Limits**: 20/min, 1,000/day on core endpoints
+
**Limitations**:
- - 3x lower rate limits
+ - Lower rate limits
- Cannot manage URLs later
- No access to private stats
- Cannot use URL management endpoints
-
+
**How to use**: Simply make requests without authentication header.
-## Rate Limit Headers
-
-When you make requests to rate-limited endpoints, the response will include headers indicating your current usage:
-
-```http
-X-RateLimit-Limit: 60
-X-RateLimit-Remaining: 45
-X-RateLimit-Reset: 1640995200
-Retry-After: 30
-```
-
## Handling Rate Limits
When you exceed the rate limit, you'll receive a `429 Too Many Requests` response:
```json
{
- "error": "Rate limit exceeded"
+ "error": "Too many requests",
+ "code": "rate_limit_exceeded"
}
```
+The API does not currently send `X-RateLimit-*` headers, so build your own client-side pacing against the documented limits and back off when you receive a 429.
+
## Need Higher Limits?
If you need higher rate limits for your application:
@@ -220,6 +275,13 @@ If you need higher rate limits for your application:
## Next Steps
+
+ The three ways to authenticate, and what each unlocks
+
Get started with API key authentication
-
- Explore all available endpoints
-
Use our official SDK
-
\ No newline at end of file
+
diff --git a/self-hosting/cloud-deployment.mdx b/self-hosting/cloud-deployment.mdx
deleted file mode 100644
index f0de437..0000000
--- a/self-hosting/cloud-deployment.mdx
+++ /dev/null
@@ -1,405 +0,0 @@
----
-title: "Cloud Deployment"
-description: ""
-icon: "cloud"
----
-
-Cloud deployment is the easiest and fastest way to get your Spoo.me instance running. These platforms offer generous free tiers and handle server management automatically, making them perfect for beginners or anyone who wants a hassle-free deployment.
-
-
-Before proceeding, ensure you have completed the prerequisite steps:
-- [Set up MongoDB database](/self-hosting/setting-up-mongo)
-- [Set up authentication](/self-hosting/setting-up-authentication) (for v1 API features)
-- [Create Discord webhooks](/self-hosting/creating-discord-webhooks)
-
-
-## Platform Comparison
-
-Choose the platform that best fits your needs:
-
-| Platform | Free Tier | Custom Domain | Pros | Best For |
-|----------|-----------|---------------|------|----------|
-| **Vercel** | Generous | ✅ Free | Excellent performance, great DX | Most users |
-| **Railway** | $5 credit | ✅ Free | Simple setup, good performance | Developers |
-| **Render** | 750 hours/month | ✅ Free | Reliable, good free tier | Stable hosting |
-| **Koyeb** | Limited | ❌ Paid only | Good performance | Testing only |
-
-
-**Recommended**: Start with Vercel for the best combination of features and ease of use.
-
-
-## Method 1: Vercel Deployment (Recommended)
-
-Vercel offers the most generous free plan and excellent performance for Spoo.me deployments.
-
-
-
- 1. Create a [GitHub account](https://github.com/signup) if you don't have one
- 2. Sign up for [Vercel](https://vercel.com/signup) and connect your GitHub account
-
-
- Vercel will need access to your GitHub account to create and deploy repositories.
-
-
-
-
- Click the deploy button to start the deployment process:
-
-
-
-
-
- This will:
- - Fork the Spoo.me repository to your GitHub account
- - Set up a new Vercel project
- - Prompt you for environment variables
-
-
-
- In the deployment configuration, enter your environment variables:
-
- ### Required Variables
-
-
- Your MongoDB connection string from the [MongoDB setup guide](/self-hosting/setting-up-mongo)
-
-
-
- Discord webhook URL for contact form notifications
-
-
-
- Discord webhook URL for URL report notifications
-
-
- ### Authentication Variables (Required for v1 API)
-
-
- RSA private key for JWT signing. See [authentication setup](/self-hosting/setting-up-authentication)
-
-
-
- RSA public key for JWT verification
-
-
-
- Google OAuth client ID (required if enabling Google login)
-
-
-
- Google OAuth client secret
-
-
-
- GitHub OAuth client ID (required if enabling GitHub login)
-
-
-
- GitHub OAuth client secret
-
-
-
- Discord OAuth client ID (required if enabling Discord login)
-
-
-
- Discord OAuth client secret
-
-
- ### App Configuration
-
-
- Application secret key. Generate with: `python -c "import os; print(os.urandom(32).hex())"`
-
-
-
- Your deployment domain (e.g., `your-project.vercel.app`). Used for generating short URLs.
-
-
-
- Set to `production` for cloud deployments.
-
-
- ### Optional Variables
-
-
- Redis connection string for caching. Recommended for production performance.
-
-
-
- Sentry DSN for error tracking. Leave empty to disable.
-
-
-
- hCaptcha secret key for form spam protection.
-
-
-
- Double-check your environment variables before deploying. Incorrect values will cause deployment failures.
-
-
-
- You can deploy with minimal variables and add OAuth providers later through your platform's environment variable settings.
-
-
-
-
- 1. Click **"Deploy"** and wait for the build process to complete
- 2. Once finished, you'll receive a deployment URL like `https://your-project.vercel.app`
- 3. Test your deployment by visiting the URL
-
-
- Your Spoo.me instance should now be live and fully functional!
-
-
-
-
- To use your own domain:
-
- 1. Go to your Vercel project dashboard
- 2. Navigate to **Settings** → **Domains**
- 3. Add your custom domain
- 4. Configure DNS records as instructed by Vercel
-
-
- Vercel provides free SSL certificates for custom domains automatically.
-
-
-
-
-## Method 2: Railway Deployment
-
-Railway offers $5 in free credits upon account verification, which typically lasts 5-7 months.
-
-
-
- 1. Create a [Railway account](https://railway.app/login)
- 2. Verify your account with your email
- 3. Connect your GitHub account for additional verification
-
-
- Account verification is required to receive the $5 free credit.
-
-
-
-
- Click the Railway deployment button:
-
-
-
-
-
- If prompted, configure Railway to access your GitHub account.
-
-
-
- Railway will prompt you to fill in the required environment variables. The environment variables are the same as the ones you set up in vercel deployment.
-
- Enter the values you prepared in the prerequisite steps.
-
-
-
- 1. Click **"Deploy"** to start the deployment
- 2. Wait for the build process to complete
- 3. Copy the deployment URL from the **Deployments** tab
- 4. Your URL will look like: `https://your-project.up.railway.app`
-
-
- You may encounter a 502 error initially. This typically resolves within a few minutes as the service starts up.
-
-
-
-
-## Method 3: Render Deployment
-
-Render provides 750 hours of free hosting per month, which is sufficient for most Spoo.me deployments.
-
-
-
- Sign up for a free [Render account](https://render.com/register).
-
-
-
- Use the Render deployment button:
-
-
-
-
-
-
-
- 1. Enter a **Blueprint Name** (e.g., "Spoo.me URL Shortener")
- 2. Fill in the environment variables section. The environment variables are the same as the ones you set up in vercel deployment.
- 3. Click **"Deploy Blueprint"** to start deployment
-
-
-
- 1. Wait for the deployment to complete
- 2. Your app will be available at: `https://your-project.onrender.com`
- 3. Test the deployment by creating a short URL
-
-
- Render automatically provides SSL certificates and handles scaling for you.
-
-
-
-
-## Method 4: Koyeb Deployment
-
-Koyeb offers one free deployment, though custom domains require a paid plan.
-
-
-
- Sign up for a [Koyeb account](https://app.koyeb.com/signup).
-
-
-
- 1. Click the Koyeb deployment button:
-
-
-
-
-
- 2. Configure your deployment settings as needed
-
-
-
- In the deployment configuration:
-
- 1. Scroll to **Environment Variables**
- 2. Add your the required variables. The environment variables are the same as the ones you set up in vercel deployment.
- 3. Click **"Deploy"**
-
-
-
- Your deployment will be available at: `https://your-project.koyeb.app`
-
-
- Koyeb's free plan doesn't support custom domains. Consider upgrading or using another platform if you need a custom domain.
-
-
-
-
-## Post-Deployment Configuration
-
-After successful deployment on any platform:
-
-
-
- 1. **URL Shortening**: Create a test short URL
- 2. **Analytics**: Check that click tracking works
- 3. **Contact Form**: Submit a test contact form
- 4. **URL Reporting**: Test the URL reporting feature
-
-
- Verify that webhook notifications appear in your Discord channels.
-
-
-
-
- Most platforms provide built-in monitoring:
-
- - **Vercel**: Analytics tab in project dashboard
- - **Railway**: Metrics tab in service dashboard
- - **Render**: Metrics section in service dashboard
- - **Koyeb**: Monitoring tab in service dashboard
-
-
- Set up alerts for downtime or high error rates to stay informed about your service health.
-
-
-
-
- If you want to use your own domain:
-
- 1. Purchase a domain from a registrar
- 2. Proxy with cloudflare or any other DNS manager for faster DNS propagation
- 3. Configure DNS records as instructed
- 4. Wait for DNS propagation (usually a few minutes if you use cloudflare)
-
-
-
-## Limitations of Cloud Deployment
-
-While cloud deployment is convenient, there are some limitations to consider:
-
-
-**Limited Customization**: You cannot modify the application code or add custom features like analytics integrations or custom SEO elements.
-
-
-- **No code modifications**: The deployed version is exactly as provided in the repository, except when using vercel because they create a private fork on your gitHub account before deploying.
-- **Platform dependencies**: Your deployment depends on the chosen platform's reliability and pricing
-- **Resource limits**: Free tiers have usage limits that may affect high-traffic deployments
-
-
-If you need more control or customization, consider [Docker deployment](/self-hosting/docker-deployment) or [local development](/self-hosting/local-development) instead.
-
-
-## Troubleshooting Common Issues
-
-
-
-**Symptoms**: Deployment fails during build process
-
-**Solutions**:
-- Verify all environment variables are correctly set
-- Check that your MongoDB connection string is valid
-- Ensure webhook URLs are properly formatted
-- Review build logs for specific error messages
-
-
-
-**Symptoms**: App loads but can't create/retrieve URLs
-
-**Solutions**:
-- Test your MongoDB connection string manually
-- Verify your MongoDB Atlas IP whitelist includes 0.0.0.0/0
-- Check that your database user has proper permissions
-- Ensure your cluster is running and accessible
-
-
-
-**Symptoms**: Forms submit but no Discord notifications
-
-**Solutions**:
-- Test webhook URLs manually using curl
-- Verify Discord channels still exist
-- Check that webhook permissions haven't been revoked
-- Ensure webhook URLs are correctly configured in environment variables
-
-
-
-**Symptoms**: Custom domain doesn't work or shows SSL errors
-
-**Solutions**:
-- Verify DNS records are correctly configured
-- Wait for DNS propagation (up to 48 hours)
-- Check that your domain registrar supports the required record types
-- Ensure SSL certificates have been issued (usually automatic)
-
-
-
-## Cost Considerations
-
-All recommended platforms offer generous free tiers:
-
-- **Vercel**: Free for personal projects, very high limits
-- **Railway**: $5 credit covers ~5-7 months of usage
-- **Render**: 750 free hours per month (enough for continuous hosting)
-- **Koyeb**: Limited free tier, paid plans start at $5/month
-
-
-For most personal or small business use cases, the free tiers are more than sufficient. You can always upgrade later if needed.
-
-
-## Next Steps
-
-
-
- Need more control? Try containerized deployment
-
-
-
- Set up a local development environment for customization
-
-
diff --git a/self-hosting/configuration.mdx b/self-hosting/configuration.mdx
new file mode 100644
index 0000000..3bfba17
--- /dev/null
+++ b/self-hosting/configuration.mdx
@@ -0,0 +1,183 @@
+---
+title: "Configuration"
+description: "Every environment variable your instance reads"
+icon: "settings"
+---
+
+All configuration is environment variables. This page lists what your instance actually reads, grouped by how likely you are to need it. Most deployments only need the first two sections.
+
+## Required
+
+| Variable | Default | What it does |
+|---|---|---|
+| `MONGODB_URI` | none | MongoDB connection string. The app does not boot without it. |
+| `SECRET_KEY` | empty | Master secret for session signing. Generate with `python -c "import os; print(os.urandom(32).hex())"`. Required in practice, and boot fails if webhooks are enabled without it. |
+| `APP_URL` | `https://spoo.me` | The full public URL of your instance, scheme included. The default short-link domain is derived from it. |
+| `ENV` | `development` | Set to `production` on real deployments. Gates several safety checks. |
+
+
+`APP_URL` must match the domain you actually serve from. Requests arriving on any other hostname are treated as an unknown custom domain and answered with a 404. This is the single most common broken-deployment cause.
+
+
+## Recommended
+
+| Variable | Default | What it does |
+|---|---|---|
+| `REDIS_URI` | unset | Cache Redis for URL lookups and rate limiting. Without it the app works but every redirect hits MongoDB. |
+| `CONTACT_WEBHOOK` | empty | Discord webhook for contact form submissions. Empty disables the contact form. |
+| `URL_REPORT_WEBHOOK` | empty | Discord webhook for URL abuse reports. Empty disables reporting. |
+| `LOG_FORMAT` | `console` | Use `json` in production. |
+| `COOKIE_SECURE` | `true` | Set `false` only for local HTTP development. |
+
+## Authentication
+
+JWT signing prefers an RS256 keypair; it falls back to HS256 with `JWT_SECRET` when the keypair is absent. See [setting up authentication](/self-hosting/setting-up-authentication) for key generation.
+
+| Variable | Default | What it does |
+|---|---|---|
+| `JWT_PRIVATE_KEY` | empty | RS256 private key (preferred). |
+| `JWT_PUBLIC_KEY` | empty | RS256 public key. |
+| `JWT_SECRET` | empty | HS256 fallback secret, used only when the keypair is absent. |
+| `JWT_ISSUER` | `spoo.me` | JWT `iss` claim. |
+| `JWT_AUDIENCE` | `spoo.me.api` | JWT `aud` claim. |
+| `ACCESS_TOKEN_TTL_SECONDS` | `900` | Access token lifetime. |
+| `REFRESH_TOKEN_TTL_SECONDS` | `2592000` | Refresh token lifetime (30 days). |
+
+### OAuth providers
+
+Each provider activates when its client ID and secret are both set.
+
+| Variable | What it does |
+|---|---|
+| `GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET` / `GOOGLE_OAUTH_REDIRECT_URI` | Google sign-in |
+| `GITHUB_OAUTH_CLIENT_ID` / `GITHUB_OAUTH_CLIENT_SECRET` / `GITHUB_OAUTH_REDIRECT_URI` | GitHub sign-in |
+| `DISCORD_OAUTH_CLIENT_ID` / `DISCORD_OAUTH_CLIENT_SECRET` / `DISCORD_OAUTH_REDIRECT_URI` | Discord sign-in |
+
+### Email
+
+Transactional email (verification, password reset) uses ZeptoMail and is silently disabled when the token is empty.
+
+| Variable | Default | What it does |
+|---|---|---|
+| `ZEPTO_API_TOKEN` | empty | ZeptoMail Send Mail API token. |
+| `ZEPTO_FROM_EMAIL` | `noreply@spoo.me` | Sender address. |
+| `ZEPTO_FROM_NAME` | `spoo.me` | Sender display name. |
+
+### Captcha
+
+| Variable | What it does |
+|---|---|
+| `HCAPTCHA_SECRET` | hCaptcha server-side secret. Empty disables captcha checks. |
+| `HCAPTCHA_SITEKEY` | hCaptcha public site key rendered in forms. |
+
+## Observability
+
+| Variable | Default | What it does |
+|---|---|---|
+| `LOG_LEVEL` | `INFO` | DEBUG, INFO, WARNING, ERROR, or CRITICAL. |
+| `SENTRY_DSN` | empty | Enables Sentry error tracking. |
+| `SENTRY_SEND_PII` | `false` | Whether Sentry receives user emails and IPs. |
+| `SENTRY_TRACES_SAMPLE_RATE` | `0.1` | Transaction trace sampling. |
+| `CLARITY_ID` | empty | Microsoft Clarity tracking ID. |
+| `SAMPLE_RATE_REDIRECT` | `0.05` | Log sampling for redirects. Similar knobs exist for stats (`SAMPLE_RATE_STATS`), cache (`SAMPLE_RATE_CACHE`), and export (`SAMPLE_RATE_EXPORT`). |
+
+## Feature switches
+
+Some features have an environment master switch, a per-user feature flag in MongoDB, or both. See [feature flags](#feature-flags) below for the flag half.
+
+
+
+
+| Variable | Default | What it does |
+|---|---|---|
+| `CUSTOM_DOMAINS_ENABLED` | `false` | Master switch for the whole feature. |
+| `CUSTOM_DOMAINS_MAX_PER_USER` | `1` | Domains allowed per user. |
+| `CUSTOM_DOMAINS_CF_ZONE_ID` | unset | Cloudflare zone for SaaS custom hostnames. Setting it requires `CUSTOM_DOMAINS_CF_API_TOKEN` too, or the app refuses to boot. |
+| `CUSTOM_DOMAINS_CF_API_TOKEN` | unset | Cloudflare API token. |
+| `CUSTOM_DOMAINS_CF_CNAME_TARGET` | `customers.spoo.me` | The CNAME target shown to users. Point this at your own edge. |
+| `CUSTOM_DOMAINS_MOCK_DCV` | `false` | Development only: verification always succeeds. The app refuses to boot with this on in production. |
+
+Re-verification worker tuning: `CUSTOM_DOMAINS_REVERIFY_INTERVAL_SECONDS` (3600), `CUSTOM_DOMAINS_REVERIFY_BATCH_SIZE` (10), `CUSTOM_DOMAINS_MAX_VERIFY_AGE_SECONDS` (604800), `CUSTOM_DOMAINS_SUSPEND_AFTER_CONSECUTIVE_FAILURES` (3), `CUSTOM_DOMAINS_VERIFY_ATTEMPTS_PER_HOUR` (60).
+
+
+
+
+| Variable | Default | What it does |
+|---|---|---|
+| `WEBHOOKS_ENABLED` | `false` | Master switch for user-facing webhook deliveries. Requires `SECRET_KEY`, or the app refuses to boot. |
+| `WEBHOOKS_RUNTIME` | `auto` | Where the delivery executor runs: `auto`, `worker`, `embedded`, or `off`. |
+| `WEBHOOKS_MAX_ENDPOINTS` | `5` | Endpoints per user. |
+| `WEBHOOKS_DELIVERY_TIMEOUT_SECONDS` | `15` | Per-delivery HTTP timeout. |
+| `WEBHOOKS_MAX_CONSECUTIVE_FAILURES` | `10` | Failures before an endpoint auto-disables. |
+| `WEBHOOKS_DELIVERY_LOG_TTL_DAYS` | `30` | Retention for events and delivery logs. Changing it rebuilds the TTL indexes on next boot. |
+| `WEBHOOKS_MAX_PENDING_PER_ENDPOINT` | `1000` | Pending deliveries cap per endpoint; overflow is dropped and counted. |
+
+
+
+
+By default clicks are tracked inline. Stream mode offloads them to a worker through a dedicated Redis.
+
+| Variable | Default | What it does |
+|---|---|---|
+| `CLICK_EVENTS_SINK` | `inline` | `inline` or `stream`. |
+| `CLICK_EVENTS_QUEUE_REDIS_URI` | empty | Dedicated queue Redis for stream mode. Must be separate from the cache Redis: the queue needs noeviction and AOF persistence, while the cache evicts. |
+| `CLICK_EVENTS_WORKER_GROUPS` | `stats,hotness` | Which consumer groups the worker runs. |
+| `CLICK_EVENTS_HOTNESS_ENABLED` | `false` | Hot-URL detection consumer (required for edge cache). |
+
+The compose file ships this as an opt-in profile: `docker compose --profile click-events up`.
+
+
+
+
+Custom link previews can store uploaded og:images in Cloudflare R2. R2 activates only when all five of `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, and `R2_PUBLIC_BASE_URL` are set; without them, https image URLs still work and only data-URI uploads are rejected.
+
+Validation tuning lives under `META_TAGS_*` (fetch timeout, size caps, redirects, user agent). Async og:image validation (`META_TAGS_ASYNC_IMAGE_VALIDATION`, default true) requires the click-events queue Redis; otherwise it silently skips.
+
+
+
+
+Serves hot redirects from Cloudflare Workers KV. Activates when `EDGE_CACHE_CF_API_TOKEN` and `EDGE_CACHE_KV_NAMESPACE_ID` are set (plus `EDGE_CACHE_CF_ACCOUNT_ID` or a dev `EDGE_CACHE_API_BASE`). Requires stream-mode click events with the hotness consumer enabled, and a deployed edge worker. Tuning: `EDGE_CACHE_TTL_SECONDS` (300), `EDGE_CACHE_OG_TTL_SECONDS` (86400).
+
+
+
+
+| Variable | Default | What it does |
+|---|---|---|
+| `DB_NAME` | `url-shortener` | Mongo database name. |
+| `MAX_ACTIVE_API_KEYS` | `20` | Active API keys per user. |
+| `MAX_DATE_RANGE_DAYS` | `90` | Max stats query range. |
+| `MAX_CONTENT_LENGTH` | `1048576` | Request body cap in bytes. |
+| `URL_PASSWORD_MIN_LENGTH` | `8` | Minimum URL password length. |
+| `GEO_RULES_MAX_COUNTRIES` | `50` | Countries per geo-targeting rule set. |
+| `MAX_EMOJI_ALIAS_LENGTH` | `15` | Max emoji alias length. |
+| `EMOJI_ACCEPT_MAX_VERSION` | `15.1` | Newest Unicode emoji version accepted. |
+| `ONBOARDING_REDIRECT_ENABLED` | `false` | Redirect new signups to the onboarding wizard. Leave off unless you also run the Next.js frontend. |
+| `EDGE_COMPOSED_ERRORS` | `false` | Empty error bodies for edge-composed error pages. Leave off unless your reverse proxy composes them. |
+
+
+
+## Feature flags
+
+Some features are additionally gated per user by documents in the `feature_flags` MongoDB collection: `custom_domains`, `geo_targeting`, `custom_meta_tags`, and `webhooks`. An unregistered flag is off for everyone. To enable a feature for all users on your instance:
+
+```javascript
+db.feature_flags.updateOne(
+ { name: "custom_domains" },
+ {
+ $set: { enabled: true, rollout_type: "everyone", updated_at: new Date() },
+ $setOnInsert: { created_at: new Date(), name: "custom_domains" }
+ },
+ { upsert: true }
+)
+```
+
+Run it in `mongosh` against your database (`use url-shortener` first), once per flag you want on.
+
+## Boot failures to know about
+
+These combinations stop the app from starting rather than degrading quietly:
+
+- `CUSTOM_DOMAINS_MOCK_DCV=true` together with `ENV=production`
+- `WEBHOOKS_ENABLED=true` with an empty `SECRET_KEY`
+- `CUSTOM_DOMAINS_CF_ZONE_ID` set without `CUSTOM_DOMAINS_CF_API_TOKEN`
+- An `APP_URL` without a valid scheme and host
diff --git a/self-hosting/creating-discord-webhooks.mdx b/self-hosting/creating-discord-webhooks.mdx
index 1d7b262..e7f0921 100644
--- a/self-hosting/creating-discord-webhooks.mdx
+++ b/self-hosting/creating-discord-webhooks.mdx
@@ -40,10 +40,6 @@ This webhook will receive all contact form submissions from your Spoo.me instanc
1. In the channel settings, click on the **"Integrations"** tab in the sidebar
2. Look for the **"Webhooks"** section
3. Click **"Create Webhook"** or **"New Webhook"**
-
-
-
-
@@ -272,15 +268,15 @@ Follow these security practices when handling webhooks:
With your webhooks configured, you're ready to proceed with deployment:
-
+
Quick deployment using platforms like Vercel or Railway
-
+
Containerized deployment with Docker
-
+
Manual setup for development and testing
diff --git a/self-hosting/deploy-koyeb.mdx b/self-hosting/deploy-koyeb.mdx
new file mode 100644
index 0000000..0328dd6
--- /dev/null
+++ b/self-hosting/deploy-koyeb.mdx
@@ -0,0 +1,61 @@
+---
+title: "Deploy on Koyeb"
+description: "Deploy Spoo.me on Koyeb's free tier"
+icon: "cloud"
+---
+
+Koyeb offers one free deployment. Note that custom domains require a paid plan, so this platform suits testing more than production.
+
+
+Complete the prerequisites first:
+- [Set up MongoDB](/self-hosting/setting-up-mongo)
+- [Set up authentication](/self-hosting/setting-up-authentication) (for v1 API features)
+- [Create Discord webhooks](/self-hosting/creating-discord-webhooks)
+
+
+## Deploy
+
+
+
+ Sign up at [app.koyeb.com](https://app.koyeb.com/signup).
+
+
+
+
+
+
+
+
+
+ Scroll to **Environment Variables** in the deployment configuration and add your values. Every variable is documented in the [configuration reference](/self-hosting/configuration).
+
+
+
+ Your app will be served at `https://your-project.koyeb.app`. Set `APP_URL` to exactly that value, scheme included, and redeploy.
+
+
+ Without a matching `APP_URL`, every request is treated as an unknown custom domain and answered with a 404.
+
+
+
+
+ 1. Open your deployment URL and shorten a test link
+ 2. Open the short link and confirm the redirect
+ 3. Submit the contact form and check the Discord webhook fires
+
+
+
+## Platform notes
+
+- The free plan does not support custom domains; upgrade or pick another platform if you need one.
+
+## Next steps
+
+
+
+ Every environment variable, explained
+
+
+ Full control on your own server
+
+
diff --git a/self-hosting/deploy-railway.mdx b/self-hosting/deploy-railway.mdx
new file mode 100644
index 0000000..c3a23ec
--- /dev/null
+++ b/self-hosting/deploy-railway.mdx
@@ -0,0 +1,62 @@
+---
+title: "Deploy on Railway"
+description: "Deploy Spoo.me from a Railway template"
+icon: "train-front"
+---
+
+Railway gives verified accounts $5 in free credits, which typically covers 5 to 7 months of a small Spoo.me instance.
+
+
+Complete the prerequisites first:
+- [Set up MongoDB](/self-hosting/setting-up-mongo)
+- [Set up authentication](/self-hosting/setting-up-authentication) (for v1 API features)
+- [Create Discord webhooks](/self-hosting/creating-discord-webhooks)
+
+
+## Deploy
+
+
+
+ Sign up at [railway.app](https://railway.app/login) and verify with your email. Connecting your GitHub account counts as additional verification. Verification is required to receive the free credit.
+
+
+
+
+
+
+
+
+
+ Railway prompts for the required variables. Every variable is documented in the [configuration reference](/self-hosting/configuration).
+
+
+
+ After deploying, copy your URL from the **Deployments** tab (it looks like `https://your-project.up.railway.app`) and set `APP_URL` to exactly that value, scheme included, then redeploy.
+
+
+ Without a matching `APP_URL`, every request is treated as an unknown custom domain and answered with a 404.
+
+
+
+
+ 1. Open your deployment URL and shorten a test link
+ 2. Open the short link and confirm the redirect
+ 3. Submit the contact form and check the Discord webhook fires
+
+
+
+## Platform notes
+
+- A 502 right after deploy is normal; it resolves within a few minutes as the service starts.
+- Railway bills by usage; watch the credit meter under your account.
+
+## Next steps
+
+
+
+ Every environment variable, explained
+
+
+ Full control on your own server
+
+
diff --git a/self-hosting/deploy-render.mdx b/self-hosting/deploy-render.mdx
new file mode 100644
index 0000000..51d4819
--- /dev/null
+++ b/self-hosting/deploy-render.mdx
@@ -0,0 +1,60 @@
+---
+title: "Deploy on Render"
+description: "Deploy Spoo.me as a Render blueprint"
+icon: "server"
+---
+
+Render provides 750 free hours per month, enough to keep one Spoo.me instance running continuously.
+
+
+Complete the prerequisites first:
+- [Set up MongoDB](/self-hosting/setting-up-mongo)
+- [Set up authentication](/self-hosting/setting-up-authentication) (for v1 API features)
+- [Create Discord webhooks](/self-hosting/creating-discord-webhooks)
+
+
+## Deploy
+
+
+
+ Sign up at [render.com](https://render.com/register).
+
+
+
+
+
+
+
+ Name the blueprint (for example "Spoo.me") and fill in the environment variables. Every variable is documented in the [configuration reference](/self-hosting/configuration).
+
+
+
+ Your app will be served at `https://your-project.onrender.com`. Set `APP_URL` to exactly that value, scheme included, and redeploy.
+
+
+ Without a matching `APP_URL`, every request is treated as an unknown custom domain and answered with a 404.
+
+
+
+
+ 1. Open your deployment URL and shorten a test link
+ 2. Open the short link and confirm the redirect
+ 3. Submit the contact form and check the Discord webhook fires
+
+
+
+## Platform notes
+
+- Render free-tier services spin down when idle and cold-start on the next request; the first hit after a quiet period is slow.
+- SSL certificates and scaling are handled automatically.
+
+## Next steps
+
+
+
+ Every environment variable, explained
+
+
+ Full control on your own server
+
+
diff --git a/self-hosting/deploy-vercel.mdx b/self-hosting/deploy-vercel.mdx
new file mode 100644
index 0000000..617306c
--- /dev/null
+++ b/self-hosting/deploy-vercel.mdx
@@ -0,0 +1,66 @@
+---
+title: "Deploy on Vercel"
+description: "One-click Spoo.me deployment on Vercel's free tier"
+icon: "triangle"
+---
+
+Vercel offers a generous free plan and the fastest path from zero to a running instance. This is the recommended platform if you just want your own Spoo.me without managing servers.
+
+
+Complete the prerequisites first:
+- [Set up MongoDB](/self-hosting/setting-up-mongo)
+- [Set up authentication](/self-hosting/setting-up-authentication) (for v1 API features)
+- [Create Discord webhooks](/self-hosting/creating-discord-webhooks)
+
+
+## Deploy
+
+
+
+
+
+
+
+ Vercel forks the repository to your GitHub account, creates a project, and prompts for environment variables.
+
+
+
+ Enter the values you prepared during the prerequisites. Every variable is documented in the [configuration reference](/self-hosting/configuration).
+
+ For `APP_URL`, use the URL your project will live at. If you plan to use the default Vercel domain, you can set a placeholder now and correct it in the next step.
+
+
+
+ After the first deploy, Vercel assigns your project a domain like `https://your-project.vercel.app`. Set `APP_URL` to exactly that URL (scheme included) in **Settings → Environment Variables**, then redeploy.
+
+
+ `APP_URL` must match the domain you serve the app from. Requests on any other hostname are treated as an unknown custom domain and answered with a 404, which makes the deployment look completely broken.
+
+
+
+
+ 1. Open your deployment URL and shorten a test link
+ 2. Open the short link and confirm the redirect
+ 3. Submit the contact form and check the Discord webhook fires
+
+
+
+## Custom domain
+
+Add your own domain in **Settings → Domains** on the Vercel dashboard, point DNS as instructed, and update `APP_URL` to the new domain. Vercel issues SSL certificates automatically.
+
+## Platform notes
+
+- The deployed code is exactly what is in your fork; pull upstream changes into the fork to update.
+- Vercel's free tier limits serverless execution time; sustained heavy traffic may need a paid plan or a VPS deployment instead.
+
+## Next steps
+
+
+
+ Every environment variable, explained
+
+
+ Full control on your own server
+
+
diff --git a/self-hosting/docker-deployment.mdx b/self-hosting/docker-deployment.mdx
index 729e2a3..e6847f8 100644
--- a/self-hosting/docker-deployment.mdx
+++ b/self-hosting/docker-deployment.mdx
@@ -397,7 +397,7 @@ sudo systemctl start docker
Set up for code customization and development
-
+
Deploy to production with managed databases
diff --git a/self-hosting/introduction.mdx b/self-hosting/introduction.mdx
index 0985c38..15fb6fa 100644
--- a/self-hosting/introduction.mdx
+++ b/self-hosting/introduction.mdx
@@ -1,162 +1,57 @@
---
title: "Introduction"
-description: ""
+description: "Run your own Spoo.me instance"
icon: "book-open"
---
-
-Spoo.me offers multiple deployment methods to suit different technical expertise levels and infrastructure preferences. Each method provides varying degrees of control, customization, and complexity.
+Spoo.me is open source and built to be self-hosted. A self-hosted instance has full feature parity with spoo.me: the same API, the same dashboard, the same analytics, on your infrastructure and your domain.
-Our support is tailored to individual needs. Should you have any inquiries, we invite you to pose them on our [Discord server](https://spoo.me/discord).
+Stuck at any point? Ask on our [Discord server](https://spoo.me/discord).
-## Server Architecture
-
-Understanding how Spoo.me works will help you choose the right deployment method and configuration for your needs.
-
-
-```mermaid
-graph TD
- A[User Request] --> B[Spoo.me Application]
- B --> C{API Version}
-
- C -->|v1 API| D{Authentication}
- C -->|v0 API| E[Legacy Endpoints]
- C -->|Web UI| F[Dashboard/SEO]
-
- D -->|JWT/API Key| G[Authenticated Requests]
- D -->|Anonymous| H[Anonymous Requests]
-
- G -->|Create URL| I[(MongoDB Database)]
- G -->|Manage URLs| J[List/Update/Delete]
- G -->|Advanced Stats| K[Analytics Engine]
-
- H -->|Shorten URL| I
- H -->|Public Stats| K
-
- E -->|Legacy Shorten| I
- E -->|Legacy Stats| K
-
- F -->|URL Redirect| L{Check Redis Cache}
- L -->|Cache Hit| M[Return Cached URL]
- L -->|Cache Miss| I
- I --> N[Update Redis Cache]
- N --> M
-
- J --> I
- K --> I
- I --> O[Analytics Data]
- O --> K
-
- F -->|Contact Form| P[Discord Webhook
Contact]
- F -->|Report URL| Q[Discord Webhook
Reports]
-
- M --> R[Redirect User]
- K --> S[Stats Response]
- R --> T[User]
- S --> T
-```
-
-
## Prerequisites
-Before deploying Spoo.me, you'll need to set up the following components:
+Every deployment method needs these two things set up first:
-
- MongoDB is required to store URLs, analytics, and user data. We recommend using MongoDB Atlas for its free tier and ease of setup.
-
+
+ MongoDB stores URLs, analytics, and user data. MongoDB Atlas has a free tier that works well.
+
- Complete guide to setting up MongoDB Atlas or self-hosted MongoDB
+ MongoDB Atlas or self-hosted MongoDB
- Webhooks are needed for the contact form and URL reporting functionality.
-
-
- Step-by-step instructions for creating Discord webhooks
+ The contact form and URL reporting deliver to Discord webhooks.
+
+
+ Step-by-step webhook setup
-## Deployment Methods
-
-Choose the deployment method that best fits your technical expertise and requirements:
+## Pick a deployment method
-
- **Recommended for beginners**
-
- Deploy with one-click using platforms like Vercel, Railway, or Render. Perfect for getting started quickly with minimal configuration.
-
- - ✅ Easy setup
- - ✅ Free hosting options
- - ✅ Automatic scaling
- - ❌ Limited customization
+
+ **Recommended.** One command brings up the app with MongoDB and Redis included. Best for anything you intend to keep running.
-
- **For intermediate users**
-
- Use Docker containers for consistent deployment across different environments. Ideal for users familiar with containerization.
-
- - ✅ Consistent environment
- - ✅ Easy scaling
- - ✅ Good customization
- - ⚠️ Requires Docker knowledge
+
+ Deploy to a free hosting platform in minutes: [Vercel](/self-hosting/deploy-vercel), [Railway](/self-hosting/deploy-railway), [Render](/self-hosting/deploy-render), or [Koyeb](/self-hosting/deploy-koyeb).
-
- **For advanced users**
-
- Manual setup with full control over the environment. Best for development, testing, and maximum customization.
-
- - ✅ Full control
- - ✅ Maximum customization
- - ✅ Best for development
- - ❌ Requires technical expertise
+
+ Run from source with hot reload. For contributing or customizing.
-
- **Performance Enhancement**
-
- Optional Redis configuration for improved caching and performance in high-traffic deployments.
-
- - ✅ Better performance
- - ✅ Caching support
- - ⚠️ Additional complexity
+
+ Every environment variable, what it does, and which ones are required.
-## What You'll Get
-
-After successful deployment, your self-hosted Spoo.me instance will include:
-
-### API v1 Features
-- 🔐 **API Key Authentication**: Create and manage API keys with scoped permissions
-- 🔗 **URL Management**: List, update, and delete shortened URLs programmatically
-- 📊 **Advanced Analytics**: Flexible filtering, grouping, and time-series analysis
-- 🔒 **Private Statistics**: Option to make URL stats private to owner only
-- 🚀 **Higher Rate Limits**: 60 requests/min for authenticated users vs 20/min for anonymous
-- ⏱️ **URL Expiration**: Set expiration dates and click limits
-- 🤖 **Bot Blocking**: Block known bots from accessing URLs
-- 🔑 **Password Protection**: Secure URLs with passwords
-
-### Legacy v0 Features
-- 🔗 **URL Shortening**: Classic URL shortening with form-based API
-- 😄 **Emoji URLs**: Create memorable emoji-based short URLs
-- 📈 **Basic Analytics**: Simple statistics and click tracking
-- 📥 **Data Export**: Export URL data in JSON, CSV, XLSX, XML formats
-
-### Web Dashboard & UI
-- 📱 **Responsive Design**: Works perfectly on all devices
-- 🎨 **Modern Interface**: Clean, intuitive dashboard for URL management
-- 🛡️ **Security Features**: Built-in protection against abuse
-- 📧 **Contact Forms**: Direct communication with webhook integration
-- 🚨 **URL Reporting**: Allow users to report problematic URLs
-
-For the best experience, ensure you have completed the prerequisite steps (MongoDB and webhooks) before proceeding with any deployment method.
+Whichever method you pick, get MongoDB and the webhooks ready first; every guide asks for them.
diff --git a/self-hosting/setting-up-authentication.mdx b/self-hosting/setting-up-authentication.mdx
index 7fa18dd..0df1c44 100644
--- a/self-hosting/setting-up-authentication.mdx
+++ b/self-hosting/setting-up-authentication.mdx
@@ -218,7 +218,7 @@ Here's a complete example `.env` configuration with all authentication settings:
```bash
# App Configuration
SECRET_KEY="your-random-secret-key-here"
-HOST_URI="127.0.0.1:8000" # Change to your domain in production
+APP_URL="http://localhost:8000" # Change to your full public URL in production
# JWT Configuration
JWT_ISSUER=spoo.me
@@ -297,7 +297,7 @@ After successful login, you should be redirected to the dashboard at `http://127
**Solution**:
- For local development, set `COOKIE_SECURE=false`
- For production with HTTPS, set `COOKIE_SECURE=true`
-- Check that `HOST_URI` matches your actual domain
+- Check that `APP_URL` matches your actual domain
- Ensure your browser allows cookies from localhost/your domain
diff --git a/self-hosting/setting-up-cloudflare.mdx b/self-hosting/setting-up-cloudflare.mdx
new file mode 100644
index 0000000..9f132b7
--- /dev/null
+++ b/self-hosting/setting-up-cloudflare.mdx
@@ -0,0 +1,146 @@
+---
+title: "Setting Up Cloudflare"
+description: "Custom domains, edge caching, and image storage for self-hosted instances"
+icon: "cloud"
+---
+
+Three optional features of a self-hosted instance are backed by Cloudflare. Each one is independent, fully off by default, and activates only when its environment variables are set:
+
+| Feature | Cloudflare product | Env prefix |
+|---|---|---|
+| [Custom domains](#custom-domains) | Cloudflare for SaaS (custom hostnames) | `CUSTOM_DOMAINS_` |
+| [Edge cache](#edge-cache) | Workers + Workers KV | `EDGE_CACHE_` |
+| [Link preview images](#link-preview-images-r2) | R2 object storage | `R2_` |
+
+You need a Cloudflare account, and for custom domains and the edge cache, your instance's domain must be a zone on that account. This page covers obtaining each credential and wiring it up; the full variable list with defaults lives in [configuration](/self-hosting/configuration).
+
+Values you will collect along the way:
+
+- **Account ID**: Cloudflare dashboard, any zone's **Overview** page, right-hand **API** panel. Also shown on the Workers & Pages overview.
+- **Zone ID**: same **API** panel on the zone's Overview page.
+- **API tokens**: dashboard, profile menu, **My Profile**, **API Tokens**, **Create Token**, **Create Custom Token**. Create one token per feature with only the permission that feature needs.
+
+## Custom domains
+
+Lets your users point their own domains at your instance and serve short links from them, TLS included. Cloudflare for SaaS issues and renews a certificate per customer hostname and routes the traffic into your zone.
+
+
+
+ In your zone, open **SSL/TLS**, then **Custom Hostnames**, and enable it. Set the **fallback origin** to a hostname in your zone that resolves to your origin server (for example `proxy-fallback.acme-links.com` with a DNS record pointing at the machine running the app). Custom-hostname traffic lands there.
+
+
+ Add a proxied (orange-cloud) DNS record in your zone that your users will point their domains at, for example `customers.acme-links.com` targeting your origin. This is the value users see in their DNS instructions.
+
+
+ Create a custom token with **Zone → SSL and Certificates → Edit**, scoped to your zone only. The app uses it to create, poll, and delete custom hostnames.
+
+
+ ```bash
+ CUSTOM_DOMAINS_ENABLED=true
+ CUSTOM_DOMAINS_CF_ZONE_ID=023e105f4ecef8ad9ca31a8372d0c353
+ CUSTOM_DOMAINS_CF_API_TOKEN=your-token
+ CUSTOM_DOMAINS_CF_CNAME_TARGET=customers.acme-links.com
+ ```
+
+
+ By default, certificate validation re-probes each hostname. If you enable **Delegated DCV** on the zone (in the Custom Hostnames settings), Cloudflare shows a per-zone delegation hostname ending in `.dcv.cloudflare.com`; set it so the app includes the extra `_acme-challenge` CNAME instruction in users' DNS setup, and certificates renew without re-probing:
+
+ ```bash
+ CUSTOM_DOMAINS_CF_DCV_DELEGATION_TARGET=abc123def456.dcv.cloudflare.com
+ ```
+
+
+
+
+Setting `CUSTOM_DOMAINS_CF_ZONE_ID` without `CUSTOM_DOMAINS_CF_API_TOKEN` fails validation and the app **refuses to boot**. Set both, or neither. With neither set, the feature simply reports itself as disabled even when `CUSTOM_DOMAINS_ENABLED=true`.
+
+
+For local development without Cloudflare, `CUSTOM_DOMAINS_MOCK_DCV=true` makes verification always succeed; the app refuses to boot with it on in production.
+
+## Edge cache
+
+Serves your hottest redirects straight from Cloudflare's edge, skipping your origin entirely. The origin detects hot links and writes them into a Workers KV namespace; a small Worker deployed on your zone answers matching requests from KV and passes everything else through untouched.
+
+This one has a prerequisite chain: promotion rides the click pipeline's hotness consumer, so you need **stream-mode click events** first:
+
+```bash
+CLICK_EVENTS_SINK=stream
+CLICK_EVENTS_QUEUE_REDIS_URI=redis://queue-redis:6379/0
+CLICK_EVENTS_HOTNESS_ENABLED=true
+```
+
+That means a second Redis (`noeviction`, persistent, separate from the cache Redis) and the click worker process running. See [configuration](/self-hosting/configuration) for the pipeline details.
+
+
+
+ The Worker lives in the main repo at `edge/spoo-edge-cache`. Edit `wrangler.jsonc` to replace the spoo.me routes with your own zone's:
+
+ ```jsonc
+ "routes": [
+ { "pattern": "acme-links.com/*", "zone_name": "acme-links.com" }
+ ]
+ ```
+
+ Then, from that directory:
+
+ ```bash
+ npm install
+ npx wrangler deploy --env production
+ ```
+
+ On first deploy, wrangler provisions the KV namespace for you and writes its id back into `wrangler.jsonc`. That id is your `EDGE_CACHE_KV_NAMESPACE_ID`. (You can also create the namespace manually under **Storage & Databases → KV** and pin its id in the config first.)
+
+
+ Create a custom token with **Account → Workers KV Storage → Edit**. The origin uses it to write cache entries; the Worker itself only reads through its binding and needs no token.
+
+
+ ```bash
+ EDGE_CACHE_CF_ACCOUNT_ID=your-account-id
+ EDGE_CACHE_CF_API_TOKEN=your-token
+ EDGE_CACHE_KV_NAMESPACE_ID=0cbb363671fc49afb8c87d5f2efb43f6
+ ```
+
+ The feature turns on only when all three are set. Hot links now promote automatically and serve from the edge for `EDGE_CACHE_TTL_SECONDS` (default 300).
+
+
+
+
+Invalidation is TTL-only: after you edit or delete a cached link, the old redirect can keep serving from the edge for up to the TTL. Keep the TTL short, and use `wrangler kv key delete` for an immediate manual purge (the key format is `cache:{domain}:{short_code}`).
+
+
+## Link preview images (R2)
+
+Custom link previews let users upload their own og:image. Uploaded images are stored in an R2 bucket and served from a public URL. Without R2, previews still work with hosted https image URLs; only direct uploads are rejected.
+
+
+
+ In the dashboard under **R2**, create a bucket, for example `acme-og-images`.
+
+
+ In the bucket's **Settings**, enable public access. A custom domain on your zone (for example `og.acme-links.com`) is the production-grade option; the managed `https://pub-....r2.dev` URL also works and is fine to start with. Whatever serves the bucket becomes the prefix of every stored image URL.
+
+
+ Under **R2 → Manage R2 API Tokens**, create a token with **Object Read & Write** on the bucket. This yields an S3-style **Access Key ID** and **Secret Access Key** (this is the one Cloudflare credential that is not a Bearer token).
+
+
+ ```bash
+ R2_ACCOUNT_ID=your-account-id
+ R2_ACCESS_KEY_ID=your-access-key-id
+ R2_SECRET_ACCESS_KEY=your-secret-access-key
+ R2_BUCKET=acme-og-images
+ R2_PUBLIC_BASE_URL=https://og.acme-links.com
+ ```
+
+ All five are required; with any missing, the feature stays off. Uploads are capped at 512 KB decoded (`R2_UPLOAD_MAX_BYTES`), and stored objects are content-addressed and immutable, so long CDN caching is safe.
+
+
+
+## Checklist
+
+| You want | You need | Refuses to boot when |
+|---|---|---|
+| Custom domains | `CUSTOM_DOMAINS_ENABLED`, `CUSTOM_DOMAINS_CF_ZONE_ID`, `CUSTOM_DOMAINS_CF_API_TOKEN`, `CUSTOM_DOMAINS_CF_CNAME_TARGET` (+ optional `CUSTOM_DOMAINS_CF_DCV_DELEGATION_TARGET`) | Zone id set without the API token |
+| Edge cache | Stream-mode clicks + hotness, the deployed Worker, `EDGE_CACHE_CF_ACCOUNT_ID`, `EDGE_CACHE_CF_API_TOKEN`, `EDGE_CACHE_KV_NAMESPACE_ID` | Never; missing values leave it off |
+| Preview image uploads | `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, `R2_PUBLIC_BASE_URL` | Never; missing values leave it off |
+
+Full variable reference, defaults, and tuning knobs: [configuration](/self-hosting/configuration).
diff --git a/self-hosting/setting-up-mongo.mdx b/self-hosting/setting-up-mongo.mdx
index 3639508..78b52e7 100644
--- a/self-hosting/setting-up-mongo.mdx
+++ b/self-hosting/setting-up-mongo.mdx
@@ -102,72 +102,6 @@ When deploying your Spoo.me instance, you'll use your connection string as the `
MONGODB_URI=mongodb+srv://username:password@cluster0.xxxxx.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0
```
-## Alternative: Self-Hosted MongoDB
-
-If you prefer to host MongoDB on your own server, you can install MongoDB Community Edition:
-
-
-
-```bash
-# Import MongoDB public GPG key
-wget -qO - https://www.mongodb.org/static/pgp/server-7.0.asc | sudo apt-key add -
-
-# Add MongoDB repository
-echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-7.0.list
-
-# Update package database
-sudo apt-get update
-
-# Install MongoDB
-sudo apt-get install -y mongodb-org
-
-# Start MongoDB service
-sudo systemctl start mongod
-sudo systemctl enable mongod
-```
-
-Your connection string would be:
-```
-mongodb://localhost:27017
-```
-
-
-
-# Download MongoDB Community Edition
-https://www.mongodb.com/try/download/community
-
-# Install MongoDB command line tools
-https://www.mongodb.com/try/download/database-tools
-
-# Start MongoDB server
-```bash
-mongod --dbpath "C:\your-preffered-path" --port 27017
-```
-
-# Connection string
-`mongodb://localhost:27017`
-
-
-
-
-```bash
-# Run MongoDB in Docker container
-docker run -d \
- --name mongodb \
- -p 27017:27017 \
- -v mongodb_data:/data/db \
- mongo:7.0
-
-# Connection string
-mongodb://localhost:27017
-```
-
-
-
-
-Self-hosted MongoDB requires additional configuration for security, backups, and monitoring. MongoDB Atlas handles these concerns automatically.
-
-
## Verification
To verify your MongoDB setup is working correctly:
@@ -206,11 +140,11 @@ Once you have your `MONGODB_URI` connection string ready, you can proceed to the
With MongoDB configured, you can now:
-
+
Set up Discord webhooks for contact forms and URL reporting
-
+
Select your preferred deployment approach
diff --git a/self-hosting/setting-up-redis.mdx b/self-hosting/setting-up-redis.mdx
index f912568..f93ccb1 100644
--- a/self-hosting/setting-up-redis.mdx
+++ b/self-hosting/setting-up-redis.mdx
@@ -50,16 +50,6 @@ Cloud Redis services handle maintenance, backups, and scaling automatically.
- **Free Tier**: 30MB storage, perfect for caching
- **Managed Service**: Automatic updates and monitoring
- **Global Availability**: Low-latency access worldwide
-
-### AWS ElastiCache
-- **Integration**: Perfect if using AWS for hosting
-- **Scaling**: Easy to scale up as needed
-- **Security**: VPC integration and encryption
-
-### Google Cloud Memorystore
-- **Integration**: Ideal for Google Cloud deployments
-- **Performance**: High-performance SSD storage
-- **Monitoring**: Built-in monitoring and alerting
diff --git a/tools/github-action.mdx b/tools/github-action.mdx
index 74ba758..bbc7e02 100644
--- a/tools/github-action.mdx
+++ b/tools/github-action.mdx
@@ -166,10 +166,10 @@ The service will be available at the URL provided in the `service-url` output.
Use the running service to test all your integration scenarios.
```yaml
- - name: Test analytics endpoint
+ - name: Test stats endpoint
run: |
- # Test analytics after creating a short URL
- curl -s ${{ steps.spoo.outputs.service-url }}/api/analytics
+ # Test stats after creating a short URL
+ curl -s "${{ steps.spoo.outputs.service-url }}/api/v1/stats?scope=all"
- name: Test error handling
run: |
diff --git a/tools/python-library.mdx b/tools/python-library.mdx
index a735650..c829197 100644
--- a/tools/python-library.mdx
+++ b/tools/python-library.mdx
@@ -6,6 +6,10 @@ icon: "package"
The official Python library makes it easy to integrate Spoo.me's URL shortening service into your Python applications.
+
+ `py_spoo_url` currently targets the **legacy v0 API** only. It does not support API keys or v1-only features such as URL management, custom domains, or the v1 stats endpoint. For those, call the [v1 API](/quickstart) directly until the new SDK ships.
+
+
## Installation
Install the library using pip:
diff --git a/trust/link-safety.mdx b/trust/link-safety.mdx
new file mode 100644
index 0000000..cc01412
--- /dev/null
+++ b/trust/link-safety.mdx
@@ -0,0 +1,66 @@
+---
+title: "Link Safety"
+description: "How spoo.me blocks malicious destinations and what visitors see"
+icon: "shield"
+---
+
+spoo.me enforces safety at both ends of a link's life: malicious destinations are rejected at creation, and links that turn malicious later are blocked and stop redirecting.
+
+## Blocked destinations at creation
+
+Every destination URL is checked against a blocklist when a link is created or edited. This covers the main destination and every per-country destination on geo-targeted links.
+
+A blocked destination is rejected with `400`:
+
+```json
+{
+ "error": "URL is blocked",
+ "code": "validation_error",
+ "field": "long_url"
+}
+```
+
+
+ The legacy v0 API (`POST /`) returns the same rejection as `403` with body `{"BlockedUrlError": "Blocked URL"}`. New integrations should use `POST /api/v1/shorten`.
+
+
+You also cannot shorten a spoo.me link itself: destinations pointing back at the shortener are rejected at creation with `400` and the message `URL is not allowed or invalid`, which closes off redirect loops.
+
+## The 451 page
+
+When an existing link is confirmed malicious, usually via [user reports](/trust/reporting-links), it is blocked. A blocked link stops redirecting entirely: visitors get an HTTP `451` page stating the link was blocked for abuse. The destination is never disclosed, not on the error page and not on the link's [preview page](/analytics/link-previews), which shows status `blocked` with the destination withheld.
+
+The block applies everywhere the link was shared, immediately. The page also points visitors to the report form, so a blocked link becomes a dead end rather than a live threat.
+
+
+Machine callers can distinguish outcomes by status code:
+
+| Status | Meaning |
+| --- | --- |
+| `302` | Healthy link, redirect served |
+| `404` | No link exists for this code |
+| `410` | The link expired or was deactivated |
+| `451` | The link was blocked for abuse |
+
+## The claimable 404
+
+Visiting a code that has never been claimed, like `spoo.me/acme-launch`, returns a `404` page that says so plainly and lets you claim the alias on the spot by shortening a link with it. A dead end becomes a starting point, and because the page is explicit that the link never existed, it cannot be confused with a link that was removed or blocked.
+
+## Bot blocking
+
+Every link has an optional **Block bots** setting, off by default. Toggle it in the link's settings or via the API:
+
+```bash
+curl -X PATCH "https://spoo.me/api/v1/urls/URL_ID" \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"block_bots": true}'
+```
+
+With bot blocking on, clicks from known bot user agents (crawlers, scrapers, link scanners) are kept out of your analytics. The redirect itself still resolves, so link unfurls in chat apps and legitimate preview fetches keep working; the bot traffic just never pollutes your click counts.
+
+
+ Links created on the legacy platform generation behave more strictly: with bot blocking on, bots receive a `403` instead of the redirect.
+
+
+Whether or not blocking is enabled, bot detection runs on every click, and the bot blocking state of any link is visible on its [public stats page](/analytics/public-stats).
diff --git a/trust/reporting-links.mdx b/trust/reporting-links.mdx
new file mode 100644
index 0000000..b2fefd6
--- /dev/null
+++ b/trust/reporting-links.mdx
@@ -0,0 +1,106 @@
+---
+title: "Reporting Links"
+description: "Report malicious short links, individually or in bulk"
+icon: "flag"
+---
+
+If a spoo.me link is being used for phishing, malware, or spam, report it. Reports go straight to the operators, and confirmed-malicious links are blocked platform-wide.
+
+
+ Not sure whether a link is malicious? Append `+` to it (`spoo.me/acme+`) to [preview the destination](/analytics/link-previews) without visiting it.
+
+
+## Reporting from the web
+
+The report form lives at [spoo.me/report](https://spoo.me/report). No account required.
+
+
+
+ A bare code (`acme`), the full short URL (`spoo.me/acme`), or a branded short URL on a custom domain all work. You can add several links to one report.
+
+
+ Choose the closest match: **phishing**, **malware**, **spam**, **illegal content**, or **other**. Optionally add free-text details and where the link reached you (SMS, email, DM, social, web).
+
+
+ Anonymous reports are captcha-gated. You can leave a contact email if you want follow-up on the resolution.
+
+
+
+
+
+
+
+
+## Bulk reporting for researchers
+
+Security researchers and threat-intel feeds can submit reports programmatically via `POST /api/v1/reports`. The endpoint is bulk-first: one request carries up to **25 links anonymously** (captcha required) or up to **100 links authenticated**. API keys need the `reports:create` scope.
+
+
+
+```bash curl
+curl -X POST "https://spoo.me/api/v1/reports" \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "reporter_org": "Acme Threat Intel",
+ "reporter_email": "abuse@acme.com",
+ "items": [
+ {"code_or_url": "https://spoo.me/acme", "reason": "phishing", "vector": "sms"},
+ {"code_or_url": "xk39dm", "reason": "malware", "details": "drops stealer payload"}
+ ]
+ }'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/reports",
+ headers={"Authorization": "Bearer YOUR_API_KEY"},
+ json={
+ "reporter_org": "Acme Threat Intel",
+ "reporter_email": "abuse@acme.com",
+ "items": [
+ {"code_or_url": "https://spoo.me/acme", "reason": "phishing", "vector": "sms"},
+ {"code_or_url": "xk39dm", "reason": "malware", "details": "drops stealer payload"},
+ ],
+ },
+)
+print(response.json())
+```
+
+
+
+The response gives a per-item breakdown, so bad entries never sink the batch:
+
+```json
+{
+ "submission_id": "665f1c2ab7e94d0c8a1f2b3c",
+ "accepted": 1,
+ "rejected": [
+ {"index": 1, "input": "xk39dm", "code": "not_found"}
+ ]
+}
+```
+
+| Rejection code | Meaning |
+| --- | --- |
+| `invalid_input` | The entry cannot name a short link (bad scheme, malformed URL) |
+| `not_found` | No short link exists for that code or URL |
+| `duplicate_in_batch` | The same link appeared earlier in this request; the first occurrence counts |
+
+Reporting the same link again later does not file a duplicate: it increments the existing report's counter, which raises its priority for review.
+
+**Rate limits** (per submission, not per item): authenticated 30/min, 500/day; anonymous 5/min, 40/day.
+
+## What happens after a report
+
+Every accepted report is stored and the operators are notified once per submission. Reports are reviewed against the link's actual destination; your selected reason is a triage hint, not a verdict, so a mislabeled report still gets looked at.
+
+If the link is confirmed malicious, it is blocked. A blocked link stops redirecting and serves an HTTP `451` page explaining that the link was blocked for abuse, to everyone, everywhere it was shared. See [Link safety](/trust/link-safety) for what that page looks like and how blocking works.
+
+If you left a contact email, it may be used to follow up on the resolution.
+
+## Contacting the operators
+
+For anything that is not a link report, such as abuse appeals or general inquiries, use the contact form at [spoo.me/contact](https://spoo.me/contact) or `POST /api/v1/contact` with `email` and `message`. Rate limit: 5/min, 20/hour, 50/day.
diff --git a/webhooks/discord-and-slack.mdx b/webhooks/discord-and-slack.mdx
new file mode 100644
index 0000000..8da7b10
--- /dev/null
+++ b/webhooks/discord-and-slack.mdx
@@ -0,0 +1,91 @@
+---
+title: "Discord and Slack"
+description: "Zero-code notification cards for your channels"
+icon: "message-square"
+---
+
+You do not need to run a server to get value out of webhooks. Every endpoint has a **flavor** that controls how the payload is presented:
+
+| Flavor | Body | For |
+|---|---|---|
+| `raw` | The Standard Webhooks JSON envelope | Your own code |
+| `discord` | A ready-to-post Discord message | A Discord channel webhook URL |
+| `slack` | A ready-to-post Block Kit message | A Slack incoming webhook URL |
+
+With `discord` or `slack`, the endpoint URL is not your server; it is the webhook URL Discord or Slack gives you. spoo.me renders each event into a formatted card and posts it straight into your channel.
+
+## Discord
+
+
+
+ In Discord, open the channel's settings, then **Integrations**, then **Webhooks**, and create one. Copy the webhook URL; it looks like `https://discord.com/api/webhooks/1234.../AbCd...`.
+
+
+ ```bash
+ curl -X POST https://spoo.me/api/v1/webhooks \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "url": "https://discord.com/api/webhooks/1234567890/AbCdEfGh",
+ "events": ["link.expired", "link.updated"],
+ "flavor": "discord",
+ "description": "Acme launch alerts"
+ }'
+ ```
+ Or pick **Discord** as the destination when creating the endpoint in the dashboard.
+
+
+ Use a [test send](/webhooks/managing-deliveries#test-sends) with any event type to see the card in your channel before real traffic arrives.
+
+
+
+Events arrive as compact cards posted by a **spoo.me** bot user: an accent color per event type (green for created, red for deleted, orange for expired), a heading that links to the short URL, the substance of the event, and a timestamp Discord renders in each viewer's local time. Clicks show device and traffic source with a running click count; edits render an old/new diff of exactly what changed.
+
+
+Payload values never ping anyone: mentions are disabled on every message spoo.me posts.
+
+## Slack
+
+
+
+ In Slack, create an app (or use an existing one) at [api.slack.com/apps](https://api.slack.com/apps), enable **Incoming Webhooks**, and add one to your channel. Copy the URL; it looks like `https://hooks.slack.com/services/T000/B000/XXXX`.
+
+
+ ```bash
+ curl -X POST https://spoo.me/api/v1/webhooks \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
+ "events": ["link.*"],
+ "flavor": "slack",
+ "description": "Acme link activity"
+ }'
+ ```
+
+
+ Same as Discord: a test send shows the card in-channel immediately.
+
+
+
+Slack messages are Block Kit: a titled section linking the short URL, a field grid for the event's details (edits render as `old → new` pairs), and a context footer with the event time. The notification toast and any unexpanded preview show a one-line summary.
+
+
+## Flavor or raw?
+
+Flavors are **lossy renderings for humans**. The `raw` envelope is the versioned contract for machines. Pick accordingly:
+
+- **Use a flavor** when the goal is a notification in a channel. No server, no verification code, no parsing.
+- **Use raw** when code consumes the event: you get every field, the Standard Webhooks envelope, and signatures you can actually verify. Discord and Slack ignore the signature headers spoo.me still sends.
+- Do not point a `raw` endpoint at a Discord or Slack webhook URL; they will reject the body. The flavor exists precisely so you never have to translate.
+
+A few practical notes:
+
+- Delivery semantics are identical across flavors: same retries, same auto-disable rules, same delivery log. The rendered card body is stored per delivery, so the log shows exactly what was posted.
+- Discord and Slack rate-limit their webhook URLs. spoo.me treats 429s as flow control and paces deliveries to match, so a burst of clicks arrives as a steady stream of messages rather than failures. Details in [managing deliveries](/webhooks/managing-deliveries).
+- `link.clicked` on a popular link is a lot of channel messages. For notification channels, subscribing to the low-frequency lifecycle events (`link.created`, `link.updated`, `link.deleted`, `link.expired`) usually reads better.
+- You can switch an existing endpoint's flavor any time with a `PATCH`; it applies from the next delivery onward.
+
+
+Want both? Endpoints are independent. Run a `raw` endpoint into your systems and a `slack` endpoint for the team channel, subscribed to different events.
+
diff --git a/webhooks/event-types.mdx b/webhooks/event-types.mdx
new file mode 100644
index 0000000..e8b04a3
--- /dev/null
+++ b/webhooks/event-types.mdx
@@ -0,0 +1,259 @@
+---
+title: "Event types"
+description: "Every webhook event, when it fires, and its exact payload"
+icon: "list"
+---
+
+This is the full event catalog. It is also available as an API, no auth required, with the same sample payloads shown below:
+
+```bash
+curl https://spoo.me/api/v1/webhooks/event-types
+```
+
+## The envelope
+
+Every delivery with the default `raw` flavor is a Standard Webhooks envelope. The event-specific payload lives under `data`:
+
+```json
+{
+ "id": "evt_9f2c1e4a8b7d4c6e9a1b3d5f7a9c2e4b",
+ "type": "link.clicked",
+ "timestamp": "2026-07-22T14:03:10+00:00",
+ "data": { ... }
+}
+```
+
+| Field | Meaning |
+|---|---|
+| `id` | Event identity (`evt_...`). The same fact delivered to two endpoints shares this id. |
+| `type` | The event type, from the catalog below. |
+| `timestamp` | When the event occurred, ISO 8601 with UTC offset. |
+| `data` | The event payload. |
+
+Payloads are **additive forever**: new fields may appear at any time, so parse leniently and ignore fields you do not recognize. Removals and renames would only ever happen with a new envelope version.
+
+
+If deliveries were dropped because your endpoint was over its pending cap, the next payload that lands carries an extra `dropped_since_last` field with the count. See [managing deliveries](/webhooks/managing-deliveries#pending-cap).
+
+
+## Catalog
+
+| Event | Fires when | Frequency |
+|---|---|---|
+| `link.created` | A short link is created | Low |
+| `link.updated` | A short link is edited, any field, status included | Low |
+| `link.deleted` | A short link is deleted | Low |
+| `link.clicked` | A tracked click is recorded, bots included | High |
+| `link.expired` | A link expires: click cap reached or expiry time passed | Low |
+
+Subscribe with exact names, the `link.*` category wildcard, or `*` for everything. Wildcards expand live, so `link.*` picks up future link events automatically.
+
+There is one more type, `webhook.test`, which you cannot subscribe to: it exists for [test sends](/webhooks/managing-deliveries#test-sends) only.
+
+## The link object
+
+Lifecycle events (`link.created`, `link.updated`, `link.deleted`, `link.expired`) carry a full snapshot of the link under `data.link`:
+
+```json
+{
+ "link_id": "665f1f77bcf86cd799439011",
+ "alias": "summer-drop",
+ "domain": "ac.me",
+ "short_url": "https://ac.me/summer-drop",
+ "long_url": "https://acme.com/campaign",
+ "status": "ACTIVE",
+ "password_protected": false,
+ "block_bots": false,
+ "max_clicks": null,
+ "expires_at": null,
+ "geo_rules": null,
+ "meta_tags": null,
+ "total_clicks": 4102,
+ "created_at": "2026-07-01T09:00:00+00:00"
+}
+```
+
+Feature fields (`max_clicks`, `expires_at`, `geo_rules`, `meta_tags`) are `null` when unset on the link.
+
+## link.created
+
+Fires when a short link is created, whether from the dashboard, the API, or an integration.
+
+```json
+{
+ "id": "evt_2c4e6a8b0d1f3a5c7e9b1d3f5a7c9e1b",
+ "type": "link.created",
+ "timestamp": "2026-07-01T09:00:00+00:00",
+ "data": {
+ "link": {
+ "link_id": "665f1f77bcf86cd799439011",
+ "alias": "summer-drop",
+ "domain": "ac.me",
+ "short_url": "https://ac.me/summer-drop",
+ "long_url": "https://acme.com/campaign",
+ "status": "ACTIVE",
+ "password_protected": false,
+ "block_bots": false,
+ "max_clicks": null,
+ "expires_at": null,
+ "geo_rules": null,
+ "meta_tags": null,
+ "total_clicks": 0,
+ "created_at": "2026-07-01T09:00:00+00:00"
+ }
+ }
+}
+```
+
+## link.updated
+
+Fires when the link's owner edits any field, status changes included. `data.changes` maps each edited field to its old and new values, and `data.link` is the snapshot **after** the edit.
+
+```json
+{
+ "id": "evt_4a6c8e0b2d3f5a7c9e1b3d5f7a9c1e3b",
+ "type": "link.updated",
+ "timestamp": "2026-07-08T11:24:00+00:00",
+ "data": {
+ "link": {
+ "link_id": "665f1f77bcf86cd799439011",
+ "alias": "summer-drop",
+ "domain": "ac.me",
+ "short_url": "https://ac.me/summer-drop",
+ "long_url": "https://acme.com/campaign",
+ "status": "ACTIVE",
+ "password_protected": false,
+ "block_bots": false,
+ "max_clicks": null,
+ "expires_at": null,
+ "geo_rules": null,
+ "meta_tags": null,
+ "total_clicks": 4102,
+ "created_at": "2026-07-01T09:00:00+00:00"
+ },
+ "changes": {
+ "long_url": {
+ "old": "https://acme.com/old",
+ "new": "https://acme.com/campaign"
+ }
+ }
+ }
+}
+```
+
+## link.deleted
+
+Fires when a short link is deleted. The payload is the last snapshot of the link before deletion.
+
+```json
+{
+ "id": "evt_6c8e0a2b4d5f7a9c1e3b5d7f9a1c3e5b",
+ "type": "link.deleted",
+ "timestamp": "2026-07-19T16:40:00+00:00",
+ "data": {
+ "link": {
+ "link_id": "665f1f77bcf86cd799439011",
+ "alias": "summer-drop",
+ "domain": "ac.me",
+ "short_url": "https://ac.me/summer-drop",
+ "long_url": "https://acme.com/campaign",
+ "status": "ACTIVE",
+ "password_protected": false,
+ "block_bots": false,
+ "max_clicks": null,
+ "expires_at": null,
+ "geo_rules": null,
+ "meta_tags": null,
+ "total_clicks": 4102,
+ "created_at": "2026-07-01T09:00:00+00:00"
+ }
+ }
+}
+```
+
+## link.clicked
+
+Fires for every **tracked** click, including clicks from bots; check `is_bot` if you want humans only. Two kinds of clicks produce no event: bots blocked on links with bot blocking enabled (the click never happens), and clicks served entirely from the edge cache (they are not tracked).
+
+This is the high-frequency event. A busy link can produce a large delivery volume, so make sure your receiver acknowledges fast and processes asynchronously.
+
+```json
+{
+ "id": "evt_8e0a2c4b6d7f9a1c3e5b7d9f1a3c5e7b",
+ "type": "link.clicked",
+ "timestamp": "2026-07-22T14:03:10+00:00",
+ "data": {
+ "link_id": "665f1f77bcf86cd799439011",
+ "alias": "summer-drop",
+ "domain": "ac.me",
+ "short_url": "https://ac.me/summer-drop",
+ "long_url": "https://acme.com/campaign",
+ "clicked_at": "2026-07-22T14:03:10+00:00",
+ "country": "IN",
+ "city": "Mumbai",
+ "browser": "Chrome",
+ "os": "Android",
+ "device": "mobile",
+ "user_agent": "Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Mobile Safari/537.36",
+ "referrer": "https://x.com/",
+ "utm": {
+ "source": "newsletter",
+ "medium": "email",
+ "campaign": null
+ },
+ "is_bot": false,
+ "bot_name": null,
+ "total_clicks": 4102
+ }
+}
+```
+
+Geo, browser, referrer, and UTM fields are `null` when unknown. `total_clicks` is the link's running total including this click.
+
+## link.expired
+
+Fires when the system discovers a link has expired: it reached its click cap, or its expiry time passed. `data.reason` is `max_clicks_reached` or `time_expired`.
+
+Owner edits that change status ride `link.updated`, not this event; `link.expired` is reserved for expiry the system discovered on its own.
+
+```json
+{
+ "id": "evt_0a2c4e6b8d9f1a3c5e7b9d1f3a5c7e9b",
+ "type": "link.expired",
+ "timestamp": "2026-07-23T02:11:45+00:00",
+ "data": {
+ "link": {
+ "link_id": "665f1f77bcf86cd799439011",
+ "alias": "summer-drop",
+ "domain": "ac.me",
+ "short_url": "https://ac.me/summer-drop",
+ "long_url": "https://acme.com/campaign",
+ "status": "EXPIRED",
+ "password_protected": false,
+ "block_bots": false,
+ "max_clicks": 5000,
+ "expires_at": null,
+ "geo_rules": null,
+ "meta_tags": null,
+ "total_clicks": 5000,
+ "created_at": "2026-07-01T09:00:00+00:00"
+ },
+ "reason": "max_clicks_reached"
+ }
+}
+```
+
+## webhook.test
+
+Sendable through [test sends](/webhooks/managing-deliveries#test-sends), never subscribable. Use it to confirm your receiver and signature verification work end to end.
+
+```json
+{
+ "id": "evt_2c4e6a8b0d1f3a5c7e9b1d3f5a7c9e1c",
+ "type": "webhook.test",
+ "timestamp": "2026-07-24T10:00:00+00:00",
+ "data": {
+ "message": "If you can read this, your endpoint works."
+ }
+}
+```
diff --git a/webhooks/managing-deliveries.mdx b/webhooks/managing-deliveries.mdx
new file mode 100644
index 0000000..dcd4886
--- /dev/null
+++ b/webhooks/managing-deliveries.mdx
@@ -0,0 +1,131 @@
+---
+title: "Managing deliveries"
+description: "Test sends, the delivery log, retries, and endpoint health"
+icon: "history"
+---
+
+Every delivery an endpoint receives is logged with its full attempt history and the exact body that was sent. This page covers the operational surface: proving an endpoint works, inspecting what happened, redelivering, and what the system does when an endpoint keeps failing.
+
+## Test sends
+
+Send a sample of any catalog event through the real pipeline:
+
+```bash
+curl -X POST https://spoo.me/api/v1/webhooks/{endpoint_id}/test \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"event_type": "link.clicked"}'
+```
+
+The sample is rendered in the endpoint's flavor, signed with the endpoint's real secret, and POSTed synchronously; the response tells you how it went:
+
+```json
+{
+ "id": "66a01b2f9d3e4c5a6b7c8d9e",
+ "webhook_id": "msg_7f3a9c1e5b2d4f6a8c0e2b4d6f8a0c2e",
+ "event_type": "link.clicked",
+ "is_test": true,
+ "status": "success",
+ "attempt_count": 1,
+ "attempts": [
+ {
+ "attempted_at": 1784908990,
+ "status_code": 200,
+ "duration_ms": 184,
+ "error": null,
+ "response_body": "{\"ok\":true}"
+ }
+ ],
+ "rendered_body": "{\"id\":\"evt_...\",\"type\":\"link.clicked\",...}",
+ "created_at": 1784908990
+}
+```
+
+Omit `event_type` (or pass `webhook.test`) for a plain test ping. Because the sample payloads are the documented ones from the [event catalog](/webhooks/event-types), a test send is also a real end-to-end check of your signature verification.
+
+Test sends are single-shot: a failing test is not retried and never counts against the endpoint's health, so you can test a broken receiver freely without pushing the endpoint toward auto-disable.
+
+## The delivery log
+
+```bash
+curl "https://spoo.me/api/v1/webhooks/{endpoint_id}/deliveries?page=1&page_size=25&status=failed" \
+ -H "Authorization: Bearer YOUR_API_KEY"
+```
+
+Each entry records the event type, current status (`pending`, `success`, `failed`), every attempt with its HTTP status code, duration, error, and the first 256 bytes of the response body, plus the exact rendered body that was sent. Filter by `status` to find what needs attention; `page_size` maxes out at 100.
+
+The same log is in the dashboard on the endpoint's page.
+
+
+
+
+
+
+## Retries
+
+A failed attempt is retried automatically on the Standard Webhooks schedule, measured from the previous attempt:
+
+| Attempt | Delay |
+|---|---|
+| 1 | Immediate |
+| 2 | 5 seconds |
+| 3 | 5 minutes |
+| 4 | 30 minutes |
+| 5 | 2 hours |
+| 6 | 5 hours |
+| 7 | 10 hours |
+
+A delivery that fails all 7 attempts (about 17.5 hours end to end) is marked `failed`. The body is frozen at the first attempt and every retry reuses the same `webhook-id`, so retries are byte-identical and safe to deduplicate. Only the timestamp and signature are fresh per attempt.
+
+**429 responses do not consume attempts.** A rate-limited delivery is held and reattempted after the receiver's `Retry-After` (capped at 15 minutes, defaulting to 60 seconds when absent), with no effect on the endpoint's failure count. This is what keeps Discord and Slack endpoints healthy under click bursts.
+
+**Paused endpoints hold deliveries.** Pause an endpoint (`PATCH` with `"status": "paused"`) and deliveries already in flight wait instead of failing; resume and they complete. New events that occur while paused are not queued, so pausing is for maintenance windows, not backlog building.
+
+### Retrying a delivery
+
+Any completed delivery can be redelivered manually within the retention window:
+
+```bash
+curl -X POST https://spoo.me/api/v1/webhooks/{endpoint_id}/deliveries/{delivery_id}/retry \
+ -H "Authorization: Bearer YOUR_API_KEY"
+```
+
+The redelivery runs synchronously and returns the updated delivery, exactly like a test send. It reuses the original `webhook-id` and body, so consumers that dedup on `webhook-id` treat it as the same message. Manual retries are single-shot and do not affect endpoint health.
+
+## Auto-disable
+
+An endpoint that keeps failing gets disabled rather than hammered forever:
+
+- **10 consecutive exhausted deliveries** (each one a full run through the retry ladder) disable the endpoint. Any successful delivery resets the count.
+- **A 410 response** disables the endpoint immediately; that status says "gone", and spoo.me takes it at its word.
+
+A disabled endpoint receives nothing: events that occur while it is disabled are not queued for it, and deliveries still pending when it was disabled are marked failed. The endpoint's `status` becomes `disabled` with a `disabled_reason` (`consecutive_failures` or `gone`), visible in the API and the dashboard.
+
+### Re-enabling
+
+Fix the receiver, then set the endpoint back to active:
+
+```bash
+curl -X PATCH https://spoo.me/api/v1/webhooks/{endpoint_id} \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"status": "active"}'
+```
+
+Re-activation clears the failure bookkeeping; the endpoint starts fresh. You cannot set `disabled` yourself; use `paused` when you want to stop deliveries deliberately.
+
+
+Before re-enabling, confirm the fix with a test send. It exercises the same path real deliveries take without waiting for the next event.
+
+
+## Pending cap
+
+Each endpoint may have at most **1,000 deliveries pending** at once. If your receiver falls that far behind (or stays rate-limited that long), new deliveries for the endpoint are dropped instead of queued, and the drops are counted.
+
+You find out two ways: the endpoint's stats in the dashboard, and a `dropped_since_last` field carried in the `data` of the next delivery that succeeds, telling you how many deliveries you missed. Flavored cards surface the same notice in the footer. The counter resets on delivery success.
+
+The cap protects the queue, not the facts; anything dropped is gone. If you hit it, your receiver is too slow for its subscription: acknowledge before processing, or narrow the subscription (fewer event types, or `scope_links` to the links you care about).
+
+## Retention
+
+Delivery logs and their event payloads are kept for **30 days**, then deleted automatically. Manual retries only work within that window, and a delivery still deferred by rate limiting when its 30 days run out is dropped with the log. Self-hosted instances can tune retention with `WEBHOOKS_DELIVERY_LOG_TTL_DAYS`; see [configuration](/self-hosting/configuration).
diff --git a/webhooks/overview.mdx b/webhooks/overview.mdx
new file mode 100644
index 0000000..5ed03a8
--- /dev/null
+++ b/webhooks/overview.mdx
@@ -0,0 +1,144 @@
+---
+title: "Webhooks"
+description: "React to link events in your own systems, in real time"
+icon: "webhook"
+---
+
+Webhooks push link events to your server the moment they happen. Instead of polling the stats API, you register an HTTPS endpoint and spoo.me sends a signed POST request for every event you subscribe to: a click lands, a link expires, a link gets edited, and your code reacts.
+
+Every delivery follows the [Standard Webhooks](https://www.standardwebhooks.com) spec, so any Standard Webhooks library verifies spoo.me signatures out of the box.
+
+
+Webhooks are being rolled out gradually, so creating endpoints may not be available on your account yet. Managing endpoints you already have is never restricted.
+
+
+## What you can build
+
+- Alert your team in Slack or Discord when a campaign link expires or hits its click cap, with zero code using the [built-in flavors](/webhooks/discord-and-slack)
+- Stream clicks into your own warehouse or analytics pipeline as they happen
+- Trigger follow-up automation when a link crosses a click threshold
+- Keep an external CMS or database in sync as links are created, edited, and deleted
+
+## A real example
+
+Say `acme` runs a product launch behind `ac.me/summer-drop` and wants a Slack ping once the link passes 10,000 clicks. Subscribe an endpoint to `link.clicked`; every event carries the link's running `total_clicks`, so the receiver is a threshold check.
+
+
+
+```bash cURL
+curl -X POST https://spoo.me/api/v1/webhooks \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "url": "https://api.acme.com/webhooks/spoo",
+ "events": ["link.clicked"],
+ "description": "Launch click alerts"
+ }'
+```
+
+```python Python
+import requests
+
+response = requests.post(
+ "https://spoo.me/api/v1/webhooks",
+ headers={"Authorization": "Bearer YOUR_API_KEY"},
+ json={
+ "url": "https://api.acme.com/webhooks/spoo",
+ "events": ["link.clicked"],
+ "description": "Launch click alerts",
+ },
+)
+
+endpoint = response.json()
+print(endpoint["signing_secret"]) # shown once, store it now
+```
+
+
+
+Each `link.clicked` delivery arrives at `https://api.acme.com/webhooks/spoo` as JSON:
+
+```json
+{
+ "id": "evt_9f2c1e4a8b7d4c6e9a1b3d5f7a9c2e4b",
+ "type": "link.clicked",
+ "timestamp": "2026-07-22T14:03:10+00:00",
+ "data": {
+ "alias": "summer-drop",
+ "short_url": "https://ac.me/summer-drop",
+ "country": "IN",
+ "device": "mobile",
+ "total_clicks": 10001
+ }
+}
+```
+
+When `data.total_clicks` crosses 10,000, your handler posts to Slack. If you only want the notification and no code at all, point the endpoint straight at a Slack webhook URL with the `slack` flavor instead.
+
+
+The create response is the only place the full `signing_secret` appears in an API response. Store it immediately; you need it to [verify deliveries](/webhooks/verifying-deliveries). You can reveal it again later from the dashboard in a signed-in session.
+
+
+Prefer clicking to curling? The same flow lives in the dashboard under **Webhooks**: add an endpoint, pick events, done.
+
+
+
+
+
+
+## Subscriptions
+
+An endpoint subscribes to one or more event types from the [catalog](/webhooks/event-types). Patterns are supported:
+
+| Pattern | Matches |
+|---|---|
+| `link.clicked` | Exactly that event |
+| `link.*` | Every link event, including ones added later |
+| `*` | Everything |
+
+By default an endpoint receives events for all your links, including links you create later. Pass `scope_links` with specific link IDs to narrow it to a fixed set.
+
+## Limits
+
+| Limit | Value |
+|---|---|
+| Endpoints per account | 5 |
+| Event patterns per endpoint | 32 |
+| Scoped links per endpoint | 256 |
+| Endpoint URL | HTTPS only, publicly reachable, max 2048 chars |
+| Payload size | 20 KB |
+| Pending deliveries per endpoint | 1,000 |
+| Delivery log retention | 30 days |
+
+Endpoint creation is limited to 10 per hour. Self-hosted instances can tune the endpoint quota and retention; see [configuration](/self-hosting/configuration).
+
+## How delivery works
+
+Deliveries are **at least once**. Rarely, you may receive the same delivery twice; deduplicate on the `webhook-id` header, which stays stable across retries of the same delivery.
+
+Your endpoint has 15 seconds to respond. Any 2xx status counts as delivered. Anything else, and spoo.me retries automatically on the Standard Webhooks schedule: immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, and 10 hours after the previous attempt. A delivery that exhausts all attempts is marked failed; you can [retry it manually](/webhooks/managing-deliveries) any time within the retention window.
+
+Two responses get special treatment:
+
+- **429**: treated as flow control, not failure. The delivery is held and reattempted after `Retry-After` (capped at 15 minutes) without consuming a retry or counting against your endpoint's health.
+- **410**: treated as "this endpoint is gone". The endpoint is disabled immediately.
+
+After 10 consecutive deliveries fail through the full retry ladder, the endpoint is **auto-disabled** and stops receiving events. Fix the receiver, then flip the endpoint back to active; that clears the failure count. Details in [managing deliveries](/webhooks/managing-deliveries).
+
+## API keys and scopes
+
+Webhook routes accept session auth or API keys with the `webhooks:manage` scope (`webhooks:read` suffices for read-only routes). One exception: revealing the signing secret requires an interactive session and refuses API keys outright.
+
+
+
+ Every event, when it fires, and its exact payload
+
+
+ Check signatures before trusting a payload
+
+
+ Zero-code notification cards
+
+
+ Test sends, the delivery log, retries, and auto-disable
+
+
diff --git a/webhooks/verifying-deliveries.mdx b/webhooks/verifying-deliveries.mdx
new file mode 100644
index 0000000..58c7cdd
--- /dev/null
+++ b/webhooks/verifying-deliveries.mdx
@@ -0,0 +1,171 @@
+---
+title: "Verifying deliveries"
+description: "Check the signature on every delivery before trusting it"
+icon: "shield-check"
+---
+
+Anyone who discovers your endpoint URL can POST JSON at it. The signature is what proves a delivery actually came from spoo.me and was not tampered with in transit. Verify it on every request before acting on the payload.
+
+spoo.me signs deliveries per the [Standard Webhooks](https://www.standardwebhooks.com) spec, so you can use any Standard Webhooks library (`pip install standardwebhooks`, `npm install standardwebhooks`) instead of the manual code below. Both work with the same secret.
+
+## The headers
+
+Every delivery carries three headers:
+
+| Header | Example | Meaning |
+|---|---|---|
+| `webhook-id` | `msg_7f3a9c1e5b2d4f6a8c0e2b4d6f8a0c2e` | Delivery identity. Stable across retries of the same delivery: your dedup key. |
+| `webhook-timestamp` | `1784908990` | Unix seconds when this attempt was sent. Fresh on every attempt. |
+| `webhook-signature` | `v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=` | One or more space-delimited signatures. |
+
+Requests also arrive with `Content-Type: application/json` and the user agent `spoo.me-webhooks/1.0 (+https://spoo.me)`.
+
+## The secret
+
+Your endpoint's signing secret looks like:
+
+```
+whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD
+```
+
+It appears in the response when you create the endpoint, and only there in the API. To read it again, open the endpoint in the dashboard and reveal it:
+
+
+
+
+
+
+Or call the API:
+
+```bash
+curl https://spoo.me/api/v1/webhooks/{endpoint_id}/secret \
+ -H "Authorization: Bearer YOUR_SESSION_TOKEN"
+```
+
+
+Revealing the secret requires an **interactive session**: you must be signed in. API keys are refused on this route, like every spoo.me operation that exposes credential material. Everywhere else, endpoint responses only show the first few characters as `signing_secret_prefix` so you can tell secrets apart.
+
+
+The HMAC key is not the string as-is: per Standard Webhooks, it is the base64-decoded portion **after** the `whsec_` prefix. The code below handles that.
+
+## The scheme
+
+The signature is HMAC-SHA256 over the string `{webhook-id}.{webhook-timestamp}.{body}`, base64-encoded and prefixed with `v1,`:
+
+```
+signature = "v1," + base64(HMAC_SHA256(base64_decode(secret_after_prefix), id + "." + timestamp + "." + body))
+```
+
+Two things matter in practice:
+
+- **Verify against the raw request bytes.** Parse the JSON only after the signature checks out; re-serializing the parsed body will not reproduce the exact bytes that were signed.
+- **The header may contain several signatures**, space-delimited. During a secret rotation grace window, deliveries are signed with both the new and the old secret. Accept the delivery if any signature matches.
+
+## Verification code
+
+
+
+```python Python
+import base64
+import hashlib
+import hmac
+import time
+
+TOLERANCE_SECONDS = 300 # 5 minutes
+
+
+def verify(secret: str, headers: dict, body: bytes) -> None:
+ """Raises ValueError unless this is an authentic, fresh delivery."""
+ msg_id = headers["webhook-id"]
+ timestamp = headers["webhook-timestamp"]
+ signatures = headers["webhook-signature"]
+
+ if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
+ raise ValueError("Timestamp outside tolerance")
+
+ key = base64.b64decode(secret.removeprefix("whsec_"))
+ signed = f"{msg_id}.{timestamp}.".encode() + body
+ expected = "v1," + base64.b64encode(
+ hmac.new(key, signed, hashlib.sha256).digest()
+ ).decode()
+
+ if not any(
+ hmac.compare_digest(expected, candidate)
+ for candidate in signatures.split(" ")
+ if candidate
+ ):
+ raise ValueError("No matching signature")
+
+
+# Example with FastAPI: read the RAW body, verify, then parse.
+#
+# @app.post("/webhooks/spoo")
+# async def receive(request: Request):
+# body = await request.body()
+# verify(SECRET, request.headers, body)
+# event = json.loads(body)
+# ...
+# return {"ok": True}
+```
+
+```javascript Node.js
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+const TOLERANCE_SECONDS = 300; // 5 minutes
+
+function verify(secret, headers, rawBody) {
+ const msgId = headers["webhook-id"];
+ const timestamp = headers["webhook-timestamp"];
+ const signatures = headers["webhook-signature"];
+
+ if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
+ throw new Error("Timestamp outside tolerance");
+ }
+
+ const key = Buffer.from(secret.slice("whsec_".length), "base64");
+ const expected =
+ "v1," +
+ createHmac("sha256", key)
+ .update(`${msgId}.${timestamp}.${rawBody}`)
+ .digest("base64");
+
+ const expectedBuf = Buffer.from(expected);
+ const ok = signatures.split(" ").some((candidate) => {
+ const candidateBuf = Buffer.from(candidate);
+ return (
+ candidateBuf.length === expectedBuf.length &&
+ timingSafeEqual(candidateBuf, expectedBuf)
+ );
+ });
+ if (!ok) throw new Error("No matching signature");
+}
+
+// Example with Express: capture the RAW body, verify, then parse.
+//
+// app.post(
+// "/webhooks/spoo",
+// express.raw({ type: "application/json" }),
+// (req, res) => {
+// verify(SECRET, req.headers, req.body.toString());
+// const event = JSON.parse(req.body);
+// ...
+// res.sendStatus(200);
+// }
+// );
+```
+
+
+
+## Replay protection
+
+An attacker who captures a signed delivery could resend it later unchanged; the signature would still verify. The timestamp closes that window:
+
+- Reject deliveries whose `webhook-timestamp` is more than **5 minutes** from your clock, in either direction. This is the Standard Webhooks convention and the code above enforces it.
+- spoo.me re-signs with a fresh timestamp on every retry attempt, so legitimate retries always pass a tolerance check. The body stays byte-identical across retries; only the timestamp and signature change.
+- Keep your server's clock synced (NTP), or legitimate deliveries will fail the check.
+
+For deduplication, track recently seen `webhook-id` values and skip any you have already processed. Retries and [manual redeliveries](/webhooks/managing-deliveries#retrying-a-delivery) reuse the same `webhook-id` with the same body.
+
+## Respond fast
+
+Verification is cheap; do it inline. Everything else should happen after you respond. Your endpoint has 15 seconds before the attempt times out, and slow handlers on a busy `link.clicked` subscription are the most common way endpoints end up in the retry ladder. Verify, enqueue, return 200.