docs: full documentation revamp for the next release - #9
Conversation
Regenerated from the live app schema instead of the frozen snapshot. Adds custom domains, alias availability check, bulk delete by domain, and device auth endpoints, plus alias constraints and domain params that were missing. Navigation gains groups for the new endpoints.
New guide pages for the custom domain lifecycle and the device authentication flow used by client apps. Corrects the API key scope table (domains scopes, urls:read grants, key management is JWT-only) and documents the verified email requirements.
Per-endpoint values were stale: management, delete, export and legacy tiers all drifted. Documents the real 429 body, notes that rate limit headers are not sent, and explains key/token/IP bucketing.
HOST_URI is no longer read by the app. APP_URL is what derives the default short link domain; without it every non-localhost request is treated as an unknown custom domain and answered with a 404. Also fixes broken deployment guide links.
Legacy errors are keyed by error type, blocked URLs return 403, and invalid emoji sequences are rejected rather than stripped. Alias charset now includes hyphen and underscore. Also fixes a nonexistent endpoint in the GitHub Action example and marks py_spoo_url as v0-only.
📝 WalkthroughWalkthroughThe PR expands API contracts and documentation for authentication, links, custom domains, analytics, webhooks, trust workflows, self-hosting, navigation, and troubleshooting. ChangesAPI documentation and contracts
Link and custom-domain workflows
Analytics and public statistics
Webhooks and trust
Self-hosting and deployment
Navigation, onboarding, and tooling
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 11
🧹 Nitpick comments (3)
custom-domains.mdx (1)
72-78: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider noting the
custom_robots_txt4096-character cap.
UpdateCustomDomainRequest(Line 5882) caps the body at 4096 chars andPATCHreturns 422 past that; the table gives no hint of the limit.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@custom-domains.mdx` around lines 72 - 78, Update the custom_robots_txt entry in the field behavior table to mention that its content is limited to 4096 characters, matching UpdateCustomDomainRequest validation and the PATCH 422 response for longer values.openapi-v1.json (1)
1537-1537: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
check-aliasdescription omits the rate limits and API key scope every sibling operation documents.
rate-limits.mdx(Lines 68-72) documents a distinct, deliberately higher tier for this endpoint (180/min, 10,000/day authenticated; 60/min, 2,000/day anonymous) andapi-keys.mdxLine 46 assigns it toshorten:create. Neither appears here, so the reference page loses the one detail callers most need for a per-keystroke endpoint.📝 Proposed description addition
- "description": "Check whether a proposed alias would be accepted by POST /api/v1/shorten.\n\nReason codes on a negative result (``length``/``format``/``taken``) let the\nUI render precise inline feedback without duplicating the validation rules.\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.", + "description": "Check whether a proposed alias would be accepted by POST /api/v1/shorten.\n\nReason codes on a negative result (``length``/``format``/``taken``) let the\nUI render precise inline feedback without duplicating the validation rules.\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.\n\n**API Key Scope**: `shorten:create` or `admin:all`\n\n**Rate Limits**:\n\n- Authenticated: 180/min, 10,000/day\n- Anonymous: 60/min, 2,000/day",🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@openapi-v1.json` at line 1537, Update the check-alias operation description to document its endpoint-specific rate limits for authenticated and anonymous callers, and identify its API key scope as shorten:create. Preserve the existing authentication, domain authorization, and reason-code documentation.api-keys.mdx (1)
48-48: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
PATCH/DELETE /api/v1/urls/*doesn't cover the new bulk delete.Bulk deletion is
DELETE /api/v1/urls(no path segment afterurls), which the glob reads as excluded. The spec (Line 3898) grants it underurls:manage.📝 Proposed fix
-| `urls:manage` | Update and delete URLs (includes read access) | `PATCH/DELETE /api/v1/urls/*` plus everything `urls:read` grants | +| `urls:manage` | Update and delete URLs (includes read access) | `PATCH/DELETE /api/v1/urls` and `/api/v1/urls/*` plus everything `urls:read` grants |🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@api-keys.mdx` at line 48, Update the urls:manage permission documentation to explicitly include the bulk deletion endpoint DELETE /api/v1/urls alongside PATCH/DELETE /api/v1/urls/*, while preserving its existing read-access grant.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@api-keys.mdx`:
- Around line 54-56: Align the API-key permissions note with the actual behavior
documented in openapi-v1.json: distinguish POST /api/v1/keys, which requires an
interactive JWT and cannot be performed by API keys, from GET and DELETE
/api/v1/keys, which may accept appropriately authorized API keys. Narrow the
security claim and admin:all statement to key creation only, or update the
OpenAPI authentication requirements if production behavior differs.
In `@custom-domains.mdx`:
- Around line 34-36: Update custom-domains.mdx lines 34-36 to distinguish the
5/hour request limit from the 3/day per-account domain quota. Update
rate-limits.mdx lines 101-107 by adding the 3/day per-account service quota to
the POST /api/v1/custom-domains row, preserving the existing 5/hour route limit.
In `@device-auth.mdx`:
- Around line 21-40: Clarify in the device-auth guide that GET
/auth/device/login is a browser-only entry page and intentionally has no OpenAPI
reference entry, while retaining the documented app_id, redirect_uri, and state
parameters. Add this clarification near the flow entry instructions, without
changing the token or refresh API documentation.
In `@openapi-v0.json`:
- Line 52: Convert openapi-v0.json from YAML syntax to valid JSON, including
quoting the pattern value and all keys or string values required by JSON.
Preserve the existing OpenAPI document structure and ensure strict JSON parsers
can load it; do not rename the file unless all consumers and parser
configuration are updated accordingly.
In `@openapi-v1.json`:
- Around line 6729-6758: Add a top-level “Custom Domains” entry to the tags
array in openapi-v1.json, including a description appropriate for the
custom-domain operations. Preserve the existing tag declarations and ensure the
name exactly matches the operation tags.
- Around line 308-328: Add a `"security": []` override to the `/auth/logout`
POST operation, alongside its existing metadata such as `operationId` and
`responses`, so it is explicitly unauthenticated and does not inherit the
document-level security requirement.
- Around line 4703-4719: Update the expire_after schemas in the affected request
definitions, including UpdateUrlRequest.expire_after, to permit string
date-time, integer Unix epoch seconds, or null. Match the existing
CreateApiKeyRequest.expires_at schema structure so the documented examples and
generated client types support both representations.
In `@quickstart.mdx`:
- Around line 13-15: Update the POST /api/v1/shorten authentication
documentation in openapi-v1.json to state that authenticated users and API keys
must belong to accounts with verified email addresses, while preserving the
existing optional-authentication and domain requirement details.
In `@rate-limits.mdx`:
- Line 13: Add an Authentication & OAuth rate-limit table in rate-limits.mdx
covering the documented /auth/* and /oauth/providers endpoints and their limits
from openapi-v1.json, so they are explicitly excluded from the global default.
Clarify the global-default statement to reference only endpoints without their
own documented limits, and note that /health has no declared rate limit.
In `@self-hosting/setting-up-authentication.mdx`:
- Line 221: Update the APP_URL example to use the same 127.0.0.1 hostname as the
OAuth redirect URI and test URLs, while preserving the documented port and
production-url guidance.
In `@tools/github-action.mdx`:
- Around line 169-172: Update the “Test stats endpoint” curl command to
authenticate the v1 scope=all request with a valid token or API key and use
--fail-with-body so HTTP errors fail the workflow; alternatively, test anonymous
stats using the captured short code from the preceding short-URL creation step.
---
Nitpick comments:
In `@api-keys.mdx`:
- Line 48: Update the urls:manage permission documentation to explicitly include
the bulk deletion endpoint DELETE /api/v1/urls alongside PATCH/DELETE
/api/v1/urls/*, while preserving its existing read-access grant.
In `@custom-domains.mdx`:
- Around line 72-78: Update the custom_robots_txt entry in the field behavior
table to mention that its content is limited to 4096 characters, matching
UpdateCustomDomainRequest validation and the PATCH 422 response for longer
values.
In `@openapi-v1.json`:
- Line 1537: Update the check-alias operation description to document its
endpoint-specific rate limits for authenticated and anonymous callers, and
identify its API key scope as shorten:create. Preserve the existing
authentication, domain authorization, and reason-code documentation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 4853d197-53d5-4444-9eb2-bf820b37fcbb
📒 Files selected for processing (13)
api-keys.mdxcustom-domains.mdxdevice-auth.mdxdocs.jsonopenapi-v0.jsonopenapi-v1.jsonquickstart.mdxrate-limits.mdxself-hosting/cloud-deployment.mdxself-hosting/introduction.mdxself-hosting/setting-up-authentication.mdxtools/github-action.mdxtools/python-library.mdx
| <Info> | ||
| Registration is limited to 5 domains per hour. | ||
| </Info> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Custom domain registration quota is understated in both guides. openapi-v1.json Line 3400 documents two independent limits for POST /api/v1/custom-domains — 5/hour on the route plus 3/day per user as a service quota — but both guides surface only the hourly route limit, so the quota users actually hit first is undocumented.
custom-domains.mdx#L34-L36: replace "limited to 5 domains per hour" with the request limit (5/hour) and the per-account domain quota (3/day); as written it also conflates requests with domains.rate-limits.mdx#L101-L107: add the3/day per accountservice quota to thePOST /api/v1/custom-domainsrow so the table isn't read as 5/hour being the only ceiling.
📍 Affects 2 files
custom-domains.mdx#L34-L36(this comment)rate-limits.mdx#L101-L107
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@custom-domains.mdx` around lines 34 - 36, Update custom-domains.mdx lines
34-36 to distinguish the 5/hour request limit from the 3/day per-account domain
quota. Update rate-limits.mdx lines 101-107 by adding the 3/day per-account
service quota to the POST /api/v1/custom-domains row, preserving the existing
5/hour route limit.
| App->>Browser: Open /auth/device/login?app_id=...&redirect_uri=...&state=... | ||
| Browser->>Spoo: User signs in and consents | ||
| Spoo->>Browser: Callback page with one-time code | ||
| Browser->>App: App reads code (and verifies state) | ||
| App->>Spoo: POST /auth/device/token {code} | ||
| Spoo->>App: access_token + refresh_token + user profile | ||
| App->>Spoo: POST /auth/device/refresh {refresh_token} | ||
| Spoo->>App: fresh access_token + refresh_token | ||
| ``` | ||
|
|
||
| <Steps> | ||
| <Step title="Start the flow in a browser"> | ||
| Open the device login page with your app's identity: | ||
|
|
||
| ``` | ||
| https://spoo.me/auth/device/login?app_id=YOUR_APP_ID&redirect_uri=YOUR_REGISTERED_URI&state=RANDOM_STATE | ||
| ``` | ||
|
|
||
| If the user is not signed in, they are taken through login first. If they have previously approved your app, the consent screen is skipped and a code is issued immediately. | ||
| </Step> |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
The flow's entry point GET /auth/device/login is not in openapi-v1.json.
The guide walks users to /auth/device/login?app_id=…&redirect_uri=…&state=…, but the spec in this PR only defines /auth/device/token and /auth/device/refresh, and docs.json (Lines 83-86) registers only those two. Readers get no reference page for the parameters (app_id, redirect_uri, state) they must construct. Either add the path to the spec or state explicitly here that it is a browser-only page with no API reference entry.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@device-auth.mdx` around lines 21 - 40, Clarify in the device-auth guide that
GET /auth/device/login is a browser-only entry page and intentionally has no
OpenAPI reference entry, while retaining the documented app_id, redirect_uri,
and state parameters. Add this clarification near the flow entry instructions,
without changing the token or refresh API documentation.
| 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_-]+$ |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Make this valid JSON.
openapi-v0.json is written as YAML; Biome reports parse failures starting at Line 52. Strict JSON/OpenAPI consumers cannot load the legacy reference. Convert the document to JSON (including quoted keys/values where required), or rename it to .yaml and update every consumer/parser configuration accordingly.
🧰 Tools
🪛 Biome (2.5.3)
[error] 52-52: String values must be double quoted.
(parse)
[error] 52-52: End of file expected
(parse)
[error] 52-52: unexpected character ^
(parse)
[error] 52-52: String values must be double quoted.
(parse)
[error] 52-52: Minus must be followed by a digit
(parse)
[error] 52-52: expected , but instead found zA
(parse)
[error] 52-52: Minus must be followed by a digit
(parse)
[error] 52-52: expected , but instead found Z0
(parse)
[error] 52-52: expected , but instead found -9
(parse)
[error] 52-52: expected , but instead found _
(parse)
[error] 52-52: End of file expected
(parse)
[error] 52-52: Minus must be followed by a digit
(parse)
[error] 52-52: unexpected character +
(parse)
[error] 52-52: String values must be double quoted.
(parse)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@openapi-v0.json` at line 52, Convert openapi-v0.json from YAML syntax to
valid JSON, including quoting the pattern value and all keys or string values
required by JSON. Preserve the existing OpenAPI document structure and ensure
strict JSON parsers can load it; do not rename the file unless all consumers and
parser configuration are updated accordingly.
Source: Linters/SAST tools
| "/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" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
/auth/logout is documented as unauthenticated but inherits the global security requirement.
Every other public operation in this spec (/auth/login, /auth/register, /auth/device/token, …) declares "security": []. Line 314 states "Authentication: Not required" and "Always succeeds regardless of whether the user was authenticated", yet this operation omits the override, so the document-level security (Lines 6765-6771) applies and generated docs/SDKs will mark logout as requiring a bearer token.
🔧 Proposed fix
}
}
- }
+ },
+ "security": []
}
},📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "/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/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`" | |
| } | |
| } | |
| } | |
| } | |
| }, | |
| "security": [] | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@openapi-v1.json` around lines 308 - 328, Add a `"security": []` override to
the `/auth/logout` POST operation, alongside its existing metadata such as
`operationId` and `responses`, so it is explicitly unauthenticated and does not
inherit the document-level security requirement.
| "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" | ||
| } | ||
| ], | ||
| "descripti |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Custom Domains tag is used by operations but never declared in the top-level tags array.
Lines 3396-3398, 3496-3498, 3604-3606, 3709-3711, 3802-3804, 3915-3917 and 4032-4034 all tag operations with Custom Domains, but the tag list here only declares URL Shortening, Link Management, Statistics, API Keys, Authentication, OAuth, and System. Tools that rely on declared tags for ordering/descriptions will render the group without a description (or at an arbitrary position).
🔧 Proposed fix
{
"name": "API Keys",
"description": "Create and manage API keys for programmatic access"
},
+ {
+ "name": "Custom Domains",
+ "description": "Register, verify, and manage domains for branded short links"
+ },
{
"name": "Authentication",📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "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" | |
| } | |
| ], | |
| "descripti | |
| "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": "Custom Domains", | |
| "description": "Register, verify, and manage domains for branded short links" | |
| }, | |
| { | |
| "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" | |
| } | |
| ], |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@openapi-v1.json` around lines 6729 - 6758, Add a top-level “Custom Domains”
entry to the tags array in openapi-v1.json, including a description appropriate
for the custom-domain operations. Preserve the existing tag declarations and
ensure the name exactly matches the operation tags.
| <Note> | ||
| 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. | ||
| </Note> |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Mirror this verified-email requirement in the POST /api/v1/shorten contract.
openapi-v1.json Line 1426 documents authentication for shorten as only "Optional — higher rate limits when authenticated. Required if domain is supplied" — nothing about email verification, even though a 403 is declared. Contrast POST /api/v1/keys (Line 3192) and POST /api/v1/custom-domains (Line 3400), which both state the requirement explicitly. Readers who go straight to the reference page won't learn why their authenticated call 403s.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@quickstart.mdx` around lines 13 - 15, Update the POST /api/v1/shorten
authentication documentation in openapi-v1.json to state that authenticated
users and API keys must belong to accounts with verified email addresses, while
preserving the existing optional-authentication and domain requirement details.
|
|
||
| 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**. |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
The "global default" claim mislabels every Authentication and OAuth endpoint.
Line 13 says any endpoint not listed below falls under 10/min, 100/hour, 500/day. The endpoint tables (Lines 61-120) cover URL shortening, link management, stats, keys, custom domains, and v0 — but no auth/OAuth endpoints, all of which carry their own documented limits in openapi-v1.json:
| Endpoint | Spec limit | Spec line |
|---|---|---|
POST /auth/login, POST /auth/register |
5/min, 50/day | 45, 138 |
POST /auth/refresh |
20/min | 241 |
POST /auth/logout |
60/hour | 314 |
GET /auth/me |
60/min | 336 |
POST /auth/send-verification |
1/min, 3/hour | 500 |
POST /auth/verify-email |
10/hour | 572 |
POST /auth/request-password-reset |
3/hour | 664 |
POST /auth/reset-password |
5/hour | 707 |
GET /oauth/providers |
60/min | 986 |
/health (Line 24) also declares no limit at all. A client that trusts Line 13 will assume 10/min for login and be surprised at 5/min, or assume 10/min for /auth/me and needlessly throttle to a sixth of the real allowance. Adding an "Authentication & OAuth" table would close the gap.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@rate-limits.mdx` at line 13, Add an Authentication & OAuth rate-limit table
in rate-limits.mdx covering the documented /auth/* and /oauth/providers
endpoints and their limits from openapi-v1.json, so they are explicitly excluded
from the global default. Clarify the global-default statement to reference only
endpoints without their own documented limits, and note that /health has no
declared rate limit.
| # 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 |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Use the same hostname throughout the local setup example.
APP_URL uses localhost, while the OAuth redirect URI and test URLs use 127.0.0.1. These are different hostnames; with the documented hostname matching behavior, this can cause authentication redirects, cookies, or requests to fail.
Proposed fix
-APP_URL="http://localhost:8000" # Change to your full public URL in production
+APP_URL="http://127.0.0.1:8000" # Change to your full public URL in production📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| APP_URL="http://localhost:8000" # Change to your full public URL in production | |
| APP_URL="http://127.0.0.1:8000" # Change to your full public URL in production |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@self-hosting/setting-up-authentication.mdx` at line 221, Update the APP_URL
example to use the same 127.0.0.1 hostname as the OAuth redirect URI and test
URLs, while preserving the documented port and production-url guidance.
| - 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" |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make the stats integration test authenticated and fail on HTTP errors.
The documented scope=all request requires authentication according to openapi-v1.json, but this curl command sends no credentials. Additionally, -s alone does not make the workflow fail on 401/403 responses, so the test can report success without testing stats.
Use a valid v1 token/API key with --fail-with-body, or switch to an authenticated-compatible test flow such as anonymous stats with a captured short code.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tools/github-action.mdx` around lines 169 - 172, Update the “Test stats
endpoint” curl command to authenticate the v1 scope=all request with a valid
token or API key and use --fail-with-body so HTTP errors fail the workflow;
alternatively, test anonymous stats using the captured short code from the
preceding short-URL creation step.
Dead deployment path segments in the mongo and webhook guides, two stale endpoint slugs on the introduction page, and a reference to an image that was never committed.
Those endpoints are intentionally not part of the public reference. The device auth guide page goes with them. Also strips em-dashes from the generated v1 spec descriptions.
The getting started page now tells the product story with dashboard screenshots instead of curl commands. Uses a short apex domain in all examples. API usage lives in the reference.
Documentation, API Reference, Self-Hosting, and spoo-qr are now separate tabs. Each cloud platform gets its own page so the product UI can link them directly, with a redirect from the old combined page. Adds a full configuration reference covering every environment variable, feature flag seeding, and boot-failure combinations. Trims the self-hosting introduction down to what a deployer actually needs.
56 paths, up from 31: public stats and preview, bulk operations, url reads, emoji set, metadata parser, reports and contact, me endpoints, and the webhooks system. License field updated to AGPL-3.0 to match the release.
One page per capability: creating links, aliases, emoji links, passwords, expiration, bulk operations, geo targeting, link previews, a seven-page custom domains section, analytics and public stats, trust and safety, account management, a five-page webhooks section, an authentication overview, and a troubleshooting page.
Rate limits now cover every endpoint tier on the upcoming release. API keys page drops the redundant method cards and limit tables in favor of the authentication and rate limit pages, and gains dashboard screenshots. Quickstart points at the composer for the visual path.
Faint brand-purple bloom on Mintlify's background layer in both themes (closes #3). Removes the self-hosted MongoDB and cloud Redis vendor menus, adds the Cloudflare setup guide, and lists setup before deploy in the sidebar.
Profile and connected apps are self-explanatory in the dashboard and do not need doc pages.
A dashboard tour covering the overview blocks, command palette, and keyboard shortcuts, plus guides for the links table (filters, natural language date ranges, bulk bar) and the analytics widget grid (custom charts, cross filtering, layouts). Screenshots from a hands-on pass in both themes.
Both themes for every state except the legacy password page, which is dark only. Dialog shots are cropped to the dialog, sections to their card, lists to the content region.
Setup checklist, needs-attention band, refresh menu, and the light theme halves of the analytics builder shots. Creating links now shows every composer step from open to created row.
There was a problem hiding this comment.
Actionable comments posted: 14
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@analytics/dimensions.mdx`:
- Around line 63-65: Update the Note describing filters in the analytics
dimensions documentation: replace the claim that filters.short_code is ignored
for scope=anon with wording that explicitly identifies it as unsupported or
invalid, while retaining the existing explanation that the top-level short_code
parameter fixes the target link.
In `@analytics/overview.mdx`:
- Around line 15-22: Update the Device row in the analytics dimensions table to
include unknown as a possible classification for unclassifiable user agents,
while preserving the existing mobile, tablet, and desktop values.
In `@analytics/public-stats.mdx`:
- Around line 24-28: Update the Charts entry in the public statistics
documentation to qualify browser, OS, device, country, city, and referrer
breakdowns as unavailable for legacy v1 links where applicable, while noting
that those links provide a bot breakdown instead. Keep the existing click and
unique-click trend description intact.
In `@api-reference/authentication.mdx`:
- Around line 121-152: Update the `EMAIL_NOT_VERIFIED` error response example to
match the documented `ErrorResponse` envelope: remove `message`, use the
lowercase `email_not_verified` code convention, and include only the supported
`error`, `code`, `field`, and `details` fields. If production actually returns
the current `message` field and uppercase code, instead update the envelope
description and `ErrorResponse` schema to document that contract consistently.
In `@custom-domains/api.mdx`:
- Around line 99-108: Update the domain verification polling loop to raise or
otherwise fail immediately on HTTP errors, and only continue polling for pending
or verifying states. Break on active, while any other status—including suspended
and revoked—must exit without another verification request; retain the existing
delay between transient-state polls.
In `@custom-domains/dns-and-verification.mdx`:
- Around line 86-90: Update the “Propagation” guidance to avoid requiring dig
ac.me CNAME +short to return customers.spoo.me for apex domains. Limit the
lookup instruction to direct-CNAME subdomains, and tell apex-domain users to
verify the configured record through their DNS provider while preserving the
existing wait-and-retry and verification-attempt guidance.
In `@openapi-v1.json`:
- Around line 4909-4911: Add OpenAPI path definitions for both device-auth
lifecycle endpoints: POST /auth/device/revoke and the device-auth login flow at
/auth/device/login, including the documented code_challenge parameter and
request/response schemas consistent with the implemented API. Update the
connected-app operation near openapi-v1.json:4909-4911 and the login-related
definition near openapi-v1.json:9549-9555; both sites require specification
changes so clients can discover and use these endpoints.
- Around line 405-407: Override the document-level OR security requirement for
the JWT-only operations by adding a security requirement of only JWTAuth to
PATCH /auth/me (around lines 405-407), POST /auth/set-password (around lines
497-499), and GET /api/v1/apps (around lines 4909-4911) in openapi-v1.json.
Ensure each operation uses [{ "JWTAuth": [] }] so API-key authentication is not
accepted.
- Around line 6910-6929: Add an operation-level security override with an empty
security array to the listWebhookEventTypes GET operation, ensuring it is
explicitly public and does not inherit the global bearer authentication
requirement.
In `@self-hosting/configuration.mdx`:
- Line 7: Update the configuration reference described in the page introduction
to include CUSTOM_DOMAINS_CF_DCV_DELEGATION_TARGET and R2_UPLOAD_MAX_BYTES,
preserving the claim that it lists every environment variable and grouping them
with the appropriate configuration sections.
In `@self-hosting/deploy-koyeb.mdx`:
- Line 7: Update the Koyeb description to state that organizations receive one
Free Instance, rather than one free deployment, and that ten custom domains are
included at no cost. Remove the claim that custom domains require a paid plan
while preserving the guidance that the platform is better suited to testing than
production.
In `@self-hosting/deploy-railway.mdx`:
- Line 7: Update the Railway pricing description in the deployment documentation
to remove the unsupported “5 to 7 months” estimate and describe the $5
verified-account credit as a 30-day trial credit. Preserve the surrounding
explanation about Railway credits and small Spoo.me instances.
In `@self-hosting/deploy-vercel.mdx`:
- Line 7: Update the recommendation paragraph in the Vercel deployment
documentation to state that the Hobby plan is restricted to personal,
non-commercial use, and direct commercial or business deployments to the Pro
plan before recommending the free tier.
In `@self-hosting/introduction.mdx`:
- Around line 15-32: Describe Discord webhooks as optional in
self-hosting/introduction.mdx, noting that CONTACT_WEBHOOK and
URL_REPORT_WEBHOOK disable their respective features when unset; remove webhook
setup from the required prerequisites in self-hosting/deploy-koyeb.mdx lines
9-14, self-hosting/deploy-railway.mdx lines 9-14, self-hosting/deploy-render.mdx
lines 9-14, and self-hosting/deploy-vercel.mdx lines 9-14.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: ec3910fc-9bb0-41bf-96c1-8ff2ad676fcc
⛔ Files ignored due to path filters (159)
images/account/api-keys-create-dark.pngis excluded by!**/*.pngimages/account/api-keys-create-light.pngis excluded by!**/*.pngimages/account/api-keys-page-dark.pngis excluded by!**/*.pngimages/account/api-keys-page-light.pngis excluded by!**/*.pngimages/account/connected-apps-dark.pngis excluded by!**/*.pngimages/account/connected-apps-light.pngis excluded by!**/*.pngimages/account/profile-settings-dark.pngis excluded by!**/*.pngimages/account/profile-settings-light.pngis excluded by!**/*.pngimages/analytics/dashboard-widgets-dark.pngis excluded by!**/*.pngimages/analytics/dashboard-widgets-light.pngis excluded by!**/*.pngimages/analytics/link-preview-page-dark.pngis excluded by!**/*.pngimages/analytics/link-preview-page-light.pngis excluded by!**/*.pngimages/analytics/public-stats-page-dark.pngis excluded by!**/*.pngimages/analytics/public-stats-page-light.pngis excluded by!**/*.pngimages/custom-domains/add-domain-dark.pngis excluded by!**/*.pngimages/custom-domains/add-domain-light.pngis excluded by!**/*.pngimages/custom-domains/dns-records-dark.pngis excluded by!**/*.pngimages/custom-domains/dns-records-light.pngis excluded by!**/*.pngimages/custom-domains/domain-active-dark.pngis excluded by!**/*.pngimages/custom-domains/domain-active-light.pngis excluded by!**/*.pngimages/custom-domains/domains-list-dark.pngis excluded by!**/*.pngimages/custom-domains/domains-list-light.pngis excluded by!**/*.pngimages/custom-domains/move-link-dark.pngis excluded by!**/*.pngimages/custom-domains/move-link-light.pngis excluded by!**/*.pngimages/custom-domains/new-link-on-domain-dark.pngis excluded by!**/*.pngimages/custom-domains/new-link-on-domain-light.pngis excluded by!**/*.pngimages/custom-domains/revoke-confirm-dark.pngis excluded by!**/*.pngimages/custom-domains/revoke-confirm-light.pngis excluded by!**/*.pngimages/dashboard/account-menu-dark.pngis excluded by!**/*.pngimages/dashboard/account-menu-light.pngis excluded by!**/*.pngimages/dashboard/analytics-add-widget-dark.pngis excluded by!**/*.pngimages/dashboard/analytics-add-widget-light.pngis excluded by!**/*.pngimages/dashboard/analytics-custom-chart-configured-dark.pngis excluded by!**/*.pngimages/dashboard/analytics-custom-chart-configured-light.pngis excluded by!**/*.pngimages/dashboard/analytics-custom-chart-dark.pngis excluded by!**/*.pngimages/dashboard/analytics-custom-chart-light.pngis excluded by!**/*.pngimages/dashboard/analytics-custom-widget-added-dark.pngis excluded by!**/*.pngimages/dashboard/analytics-custom-widget-added-light.pngis excluded by!**/*.pngimages/dashboard/analytics-dark.pngis excluded by!**/*.pngimages/dashboard/analytics-edit-mode-dark.pngis excluded by!**/*.pngimages/dashboard/analytics-edit-mode-light.pngis excluded by!**/*.pngimages/dashboard/analytics-filtered-dark.pngis excluded by!**/*.pngimages/dashboard/analytics-filtered-light.pngis excluded by!**/*.pngimages/dashboard/analytics-layout-menu-dark.pngis excluded by!**/*.pngimages/dashboard/analytics-layout-menu-light.pngis excluded by!**/*.pngimages/dashboard/analytics-light.pngis excluded by!**/*.pngimages/dashboard/analytics-pinned-widget-dark.pngis excluded by!**/*.pngimages/dashboard/analytics-pinned-widget-light.pngis excluded by!**/*.pngimages/dashboard/analytics-widget-expanded-dark.pngis excluded by!**/*.pngimages/dashboard/analytics-widget-expanded-light.pngis excluded by!**/*.pngimages/dashboard/analytics-widget-resized-dark.pngis excluded by!**/*.pngimages/dashboard/analytics-widget-resized-light.pngis excluded by!**/*.pngimages/dashboard/api-key-created-dark.pngis excluded by!**/*.pngimages/dashboard/api-keys-create-dark.pngis excluded by!**/*.pngimages/dashboard/api-keys-create-light.pngis excluded by!**/*.pngimages/dashboard/api-keys-dark.pngis excluded by!**/*.pngimages/dashboard/api-keys-list-dark.pngis excluded by!**/*.pngimages/dashboard/api-keys-list-light.pngis excluded by!**/*.pngimages/dashboard/apps-dark.pngis excluded by!**/*.pngimages/dashboard/apps-detail-dark.pngis excluded by!**/*.pngimages/dashboard/apps-detail-light.pngis excluded by!**/*.pngimages/dashboard/apps-light.pngis excluded by!**/*.pngimages/dashboard/domain-detail-dark.pngis excluded by!**/*.pngimages/dashboard/domain-detail-light.pngis excluded by!**/*.pngimages/dashboard/global-search-dark.pngis excluded by!**/*.pngimages/dashboard/global-search-light.pngis excluded by!**/*.pngimages/dashboard/global-search-results-dark.pngis excluded by!**/*.pngimages/dashboard/global-search-results-light.pngis excluded by!**/*.pngimages/dashboard/keyboard-shortcuts-dark.pngis excluded by!**/*.pngimages/dashboard/keyboard-shortcuts-light.pngis excluded by!**/*.pngimages/dashboard/link-detail-dark.pngis excluded by!**/*.pngimages/dashboard/link-detail-light.pngis excluded by!**/*.pngimages/dashboard/link-detail-settings-dark.pngis excluded by!**/*.pngimages/dashboard/link-detail-settings-light.pngis excluded by!**/*.pngimages/dashboard/link-detail-settings-meta-dark.pngis excluded by!**/*.pngimages/dashboard/link-detail-settings-meta-light.pngis excluded by!**/*.pngimages/dashboard/link-peek-dark.pngis excluded by!**/*.pngimages/dashboard/link-peek-light.pngis excluded by!**/*.pngimages/dashboard/links-bulk-actions-dark.pngis excluded by!**/*.pngimages/dashboard/links-bulk-actions-light.pngis excluded by!**/*.pngimages/dashboard/links-bulk-select-dark.pngis excluded by!**/*.pngimages/dashboard/links-bulk-select-light.pngis excluded by!**/*.pngimages/dashboard/links-daterange-dark.pngis excluded by!**/*.pngimages/dashboard/links-daterange-light.pngis excluded by!**/*.pngimages/dashboard/links-filters-dark.pngis excluded by!**/*.pngimages/dashboard/links-filters-light.pngis excluded by!**/*.pngimages/dashboard/links-list-dark.pngis excluded by!**/*.pngimages/dashboard/links-list-light.pngis excluded by!**/*.pngimages/dashboard/links-move-domain-dark.pngis excluded by!**/*.pngimages/dashboard/links-move-domain-light.pngis excluded by!**/*.pngimages/dashboard/links-refresh-menu-dark.pngis excluded by!**/*.pngimages/dashboard/links-refresh-menu-light.pngis excluded by!**/*.pngimages/dashboard/links-row-actions-dark.pngis excluded by!**/*.pngimages/dashboard/links-row-actions-light.pngis excluded by!**/*.pngimages/dashboard/new-link-alias-suggest-dark.pngis excluded by!**/*.pngimages/dashboard/new-link-alias-suggest-light.pngis excluded by!**/*.pngimages/dashboard/new-link-basic-dark.pngis excluded by!**/*.pngimages/dashboard/new-link-basic-light.pngis excluded by!**/*.pngimages/dashboard/new-link-domain-picker-dark.pngis excluded by!**/*.pngimages/dashboard/new-link-domain-picker-light.pngis excluded by!**/*.pngimages/dashboard/new-link-emoji-browser-dark.pngis excluded by!**/*.pngimages/dashboard/new-link-emoji-browser-light.pngis excluded by!**/*.pngimages/dashboard/new-link-metadata-dark.pngis excluded by!**/*.pngimages/dashboard/new-link-metadata-light.pngis excluded by!**/*.pngimages/dashboard/new-link-security-dark.pngis excluded by!**/*.pngimages/dashboard/new-link-security-light.pngis excluded by!**/*.pngimages/dashboard/new-link-targeting-dark.pngis excluded by!**/*.pngimages/dashboard/new-link-targeting-light.pngis excluded by!**/*.pngimages/dashboard/overview-dark.pngis excluded by!**/*.pngimages/dashboard/overview-light.pngis excluded by!**/*.pngimages/dashboard/overview-needs-attention-dark.pngis excluded by!**/*.pngimages/dashboard/overview-needs-attention-light.pngis excluded by!**/*.pngimages/dashboard/settings-avatar-menu-dark.pngis excluded by!**/*.pngimages/dashboard/settings-avatar-menu-light.pngis excluded by!**/*.pngimages/dashboard/settings-dark.pngis excluded by!**/*.pngimages/dashboard/settings-light.pngis excluded by!**/*.pngimages/dashboard/setup-checklist-dark.pngis excluded by!**/*.pngimages/dashboard/setup-checklist-light.pngis excluded by!**/*.pngimages/dashboard/webhook-delivery-detail-dark.pngis excluded by!**/*.pngimages/dashboard/webhook-delivery-detail-light.pngis excluded by!**/*.pngimages/dashboard/webhook-endpoint-detail-dark.pngis excluded by!**/*.pngimages/dashboard/webhook-endpoint-detail-light.pngis excluded by!**/*.pngimages/dashboard/webhooks-dark.pngis excluded by!**/*.pngimages/dashboard/webhooks-endpoint-validation-dark.pngis excluded by!**/*.pngimages/dashboard/webhooks-list-dark.pngis excluded by!**/*.pngimages/dashboard/webhooks-list-light.pngis excluded by!**/*.pngimages/dashboard/webhooks-new-endpoint-dark.pngis excluded by!**/*.pngimages/dashboard/webhooks-new-endpoint-light.pngis excluded by!**/*.pngimages/links/composer-basic-dark.pngis excluded by!**/*.pngimages/links/composer-basic-light.pngis excluded by!**/*.pngimages/links/composer-created-dark.pngis excluded by!**/*.pngimages/links/composer-created-light.pngis excluded by!**/*.pngimages/links/composer-destination-dark.pngis excluded by!**/*.pngimages/links/composer-destination-light.pngis excluded by!**/*.pngimages/links/composer-domain-picker-dark.pngis excluded by!**/*.pngimages/links/composer-domain-picker-light.pngis excluded by!**/*.pngimages/links/composer-expiration-dark.pngis excluded by!**/*.pngimages/links/composer-expiration-light.pngis excluded by!**/*.pngimages/links/composer-open-dark.pngis excluded by!**/*.pngimages/links/composer-open-light.pngis excluded by!**/*.pngimages/links/composer-security-dark.pngis excluded by!**/*.pngimages/links/composer-security-light.pngis excluded by!**/*.pngimages/links/emoji-browser-dark.pngis excluded by!**/*.pngimages/links/emoji-browser-light.pngis excluded by!**/*.pngimages/links/geo-rules-editor-dark.pngis excluded by!**/*.pngimages/links/geo-rules-editor-light.pngis excluded by!**/*.pngimages/links/links-bulk-bar-dark.pngis excluded by!**/*.pngimages/links/links-bulk-bar-light.pngis excluded by!**/*.pngimages/links/meta-tags-editor-dark.pngis excluded by!**/*.pngimages/links/meta-tags-editor-light.pngis excluded by!**/*.pngimages/links/password-prompt-dark.pngis excluded by!**/*.pngimages/trust/report-form-dark.pngis excluded by!**/*.pngimages/trust/report-form-light.pngis excluded by!**/*.pngimages/webhooks/deliveries-log-dark.pngis excluded by!**/*.pngimages/webhooks/deliveries-log-light.pngis excluded by!**/*.pngimages/webhooks/endpoints-list-dark.pngis excluded by!**/*.pngimages/webhooks/endpoints-list-light.pngis excluded by!**/*.pngimages/webhooks/reveal-secret-dark.pngis excluded by!**/*.pngimages/webhooks/reveal-secret-light.pngis excluded by!**/*.png
📒 Files selected for processing (51)
analytics/custom-dashboards.mdxanalytics/dimensions.mdxanalytics/exports.mdxanalytics/link-previews.mdxanalytics/link-stats.mdxanalytics/overview.mdxanalytics/public-stats.mdxapi-keys.mdxapi-reference/authentication.mdxcustom-domains.mdxcustom-domains/add-a-domain.mdxcustom-domains/api.mdxcustom-domains/create-links.mdxcustom-domains/dns-and-verification.mdxcustom-domains/removing-a-domain.mdxcustom-domains/routing.mdxdashboard.mdxdocs.jsonhelp/troubleshooting.mdxintroduction.mdxlinks/bulk-operations.mdxlinks/creating-links.mdxlinks/custom-aliases.mdxlinks/custom-link-previews.mdxlinks/emoji-links.mdxlinks/geo-targeting.mdxlinks/link-expiration.mdxlinks/managing-links.mdxlinks/password-protection.mdxopenapi-v1.jsonquickstart.mdxrate-limits.mdxself-hosting/cloud-deployment.mdxself-hosting/configuration.mdxself-hosting/creating-discord-webhooks.mdxself-hosting/deploy-koyeb.mdxself-hosting/deploy-railway.mdxself-hosting/deploy-render.mdxself-hosting/deploy-vercel.mdxself-hosting/docker-deployment.mdxself-hosting/introduction.mdxself-hosting/setting-up-cloudflare.mdxself-hosting/setting-up-mongo.mdxself-hosting/setting-up-redis.mdxtrust/link-safety.mdxtrust/reporting-links.mdxwebhooks/discord-and-slack.mdxwebhooks/event-types.mdxwebhooks/managing-deliveries.mdxwebhooks/overview.mdxwebhooks/verifying-deliveries.mdx
💤 Files with no reviewable changes (2)
- self-hosting/cloud-deployment.mdx
- self-hosting/setting-up-redis.mdx
| <Note> | ||
| 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. | ||
| </Note> |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Do not describe short_code filtering as silently ignored.
The v1 contract says filters.short_code is not allowed with scope=anon; “ignored” tells clients an invalid filter will succeed. Document it as unsupported while retaining the top-level short_code explanation.
Proposed fix
- 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.
+ Filter values are case-sensitive. `chrome` will not match `Chrome`. The `short_code` filter is not allowed on `scope=anon` queries, where the target link is already fixed by the `short_code` parameter.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <Note> | |
| 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. | |
| </Note> | |
| <Note> | |
| Filter values are case-sensitive. `chrome` will not match `Chrome`. The `short_code` filter is not allowed on `scope=anon` queries, where the target link is already fixed by the `short_code` parameter. | |
| </Note> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@analytics/dimensions.mdx` around lines 63 - 65, Update the Note describing
filters in the analytics dimensions documentation: replace the claim that
filters.short_code is ignored for scope=anon with wording that explicitly
identifies it as unsupported or invalid, while retaining the existing
explanation that the top-level short_code parameter fixes the target link.
| | 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 | |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Include the unknown device classification.
The dimensions guide documents unknown for unclassifiable user agents, but this table presents only three possible values.
Proposed fix
-| Device | `mobile`, `tablet`, or `desktop`, derived from the user agent |
+| Device | `mobile`, `tablet`, `desktop`, or `unknown`, derived from the user agent |📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| | 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 | | |
| | 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`, `desktop`, or `unknown`, 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 | |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@analytics/overview.mdx` around lines 15 - 22, Update the Device row in the
analytics dimensions table to include unknown as a possible classification for
unclassifiable user agents, while preserving the existing mobile, tablet, and
desktop values.
| | 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 | | ||
|
|
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Qualify device and city charts for legacy links.
The public-stats response supports legacy v1 links, whose stats omit device and city and include a bot breakdown instead. Avoid promising those charts for every link.
Proposed fix
-| Charts | Clicks and unique clicks over time, plus breakdowns by browser, OS, device, country, city, and referrer |
+| Charts | Clicks and unique clicks over time, plus breakdowns by browser, OS, country, and referrer. v2 links also include device and city; legacy v1 links include bot traffic instead. |📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| | 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 | | |
| | 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, country, and referrer. v2 links also include device and city; legacy v1 links include bot traffic instead. | |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@analytics/public-stats.mdx` around lines 24 - 28, Update the Charts entry in
the public statistics documentation to qualify browser, OS, device, country,
city, and referrer breakdowns as unavailable for legacy v1 links where
applicable, while noting that those links provide a bot breakdown instead. Keep
the existing click and unique-click trend description intact.
| ## Error responses | ||
|
|
||
| All errors use the same JSON envelope: `{"error": "<human-readable message>", "code": "<machine-readable 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. | ||
|
|
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Error envelope example contradicts the documented contract.
Line 123 states the envelope is strictly {"error", "code"} plus optional field/details, matching the ErrorResponse schema (error, code, field, details only). The EMAIL_NOT_VERIFIED example at lines 143-149 adds an undocumented message field, breaking that contract within the same file. Separately, its code value (EMAIL_NOT_VERIFIED) is UPPER_SNAKE while the sibling examples use lowercase (authentication_error, forbidden) — an inconsistent casing convention shown to integrators.
📝 Proposed fix
```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."
+ "code": "email_not_verified",
+ "field": null,
+ "details": null
}</details>
If the live API actually returns `message` and uppercase codes for this case, update line 123's description and the `ErrorResponse` schema instead so the documented envelope matches production.
<!-- suggestion_start -->
<details>
<summary>📝 Committable suggestion</summary>
> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
```suggestion
## Error responses
All errors use the same JSON envelope: `{"error": "<human-readable message>", "code": "<machine-readable code>"}`. Validation errors may add `field` or `details`.
**401 Unauthorized**: the request needs credentials and has none, or the token failed verification.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@api-reference/authentication.mdx` around lines 121 - 152, Update the
`EMAIL_NOT_VERIFIED` error response example to match the documented
`ErrorResponse` envelope: remove `message`, use the lowercase
`email_not_verified` code convention, and include only the supported `error`,
`code`, `field`, and `details` fields. If production actually returns the
current `message` field and uppercase code, instead update the envelope
description and `ErrorResponse` schema to document that contract consistently.
| 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 |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n -i -C 3 'pending|verifying|active|suspended|revoked' openapi-v1.jsonRepository: spoo-me/docs
Length of output: 29422
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== custom-domains/api.mdx relevant section =="
sed -n '70,125p' custom-domains/api.mdx
echo
echo "== references to verify custom domain docs/usages =="
rg -n -C 3 'verify.*domain|verification|pending|verifying|active|suspended|revoked|last_verification_error' custom-domains .github . 2>/dev/null | head -n 220
echo
echo "== static verifier: loop behavior for API response statuses =="
python3 - <<'PY'
statuses = ["pending", "verifying", "active", "suspended", "revoked"]
continues = [s for s in statuses if s not in {"active"}]
print("current loop breaks only on active")
print("status->continues", {s: s != "active" for s in statuses})
print("additional states that continue indefinitely without HTTP error:", [s for s in ["suspended", "revoked"] if s in continues])
PYRepository: spoo-me/docs
Length of output: 20853
Stop polling when verification cannot progress.
suspended and revoked are non-transient domain states; continuing to call POST .../verify indefinitely wastes the 60/hr check quota and does not move the domain toward active. Raise on HTTP failures and exit for any response state other than pending, verifying, or active.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@custom-domains/api.mdx` around lines 99 - 108, Update the domain verification
polling loop to raise or otherwise fail immediately on HTTP errors, and only
continue polling for pending or verifying states. Break on active, while any
other status—including suspended and revoked—must exit without another
verification request; retain the existing delay between transient-state polls.
| 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. |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Complete the configuration reference.
This promises every variable, but omits CUSTOM_DOMAINS_CF_DCV_DELEGATION_TARGET and R2_UPLOAD_MAX_BYTES, both required by the Cloudflare guide. Add them here or narrow the claim.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@self-hosting/configuration.mdx` at line 7, Update the configuration reference
described in the page introduction to include
CUSTOM_DOMAINS_CF_DCV_DELEGATION_TARGET and R2_UPLOAD_MAX_BYTES, preserving the
claim that it lists every environment variable and grouping them with the
appropriate configuration sections.
| icon: "cloud" | ||
| --- | ||
|
|
||
| Koyeb offers one free deployment. Note that custom domains require a paid plan, so this platform suits testing more than production. |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Correct Koyeb’s free-tier and domain description.
Koyeb limits organizations to one Free Instance, not one deployment, and its current pricing states that ten custom domains are included free. Remove the claim that custom domains require a paid plan. (koyeb.com)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@self-hosting/deploy-koyeb.mdx` at line 7, Update the Koyeb description to
state that organizations receive one Free Instance, rather than one free
deployment, and that ten custom domains are included at no cost. Remove the
claim that custom domains require a paid plan while preserving the guidance that
the platform is better suited to testing than production.
| icon: "train-front" | ||
| --- | ||
|
|
||
| Railway gives verified accounts $5 in free credits, which typically covers 5 to 7 months of a small Spoo.me instance. |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Remove the unsupported 5–7 month estimate.
Railway’s $5 trial credit expires after 30 days, so it cannot cover several months. Describe it as a 30-day trial credit instead. (docs.railway.com)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@self-hosting/deploy-railway.mdx` at line 7, Update the Railway pricing
description in the deployment documentation to remove the unsupported “5 to 7
months” estimate and describe the $5 verified-account credit as a 30-day trial
credit. Preserve the surrounding explanation about Railway credits and small
Spoo.me instances.
| 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. |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
State the Hobby-plan eligibility restriction.
Vercel Hobby is limited to personal, non-commercial use. Add a clear caveat directing commercial or business instances to Pro before recommending the free tier. (vercel.com)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@self-hosting/deploy-vercel.mdx` at line 7, Update the recommendation
paragraph in the Vercel deployment documentation to state that the Hobby plan is
restricted to personal, non-commercial use, and direct commercial or business
deployments to the Pro plan before recommending the free tier.
| Every deployment method needs these two things set up first: | ||
|
|
||
| <Steps> | ||
| <Step title="Set up MongoDB Database"> | ||
| MongoDB is required to store URLs, analytics, and user data. We recommend using MongoDB Atlas for its free tier and ease of setup. | ||
| <Step title="Set up MongoDB"> | ||
| MongoDB stores URLs, analytics, and user data. MongoDB Atlas has a free tier that works well. | ||
|
|
||
| <Card title="MongoDB Setup Guide" icon="database" href="/self-hosting/setting-up-mongo"> | ||
| Complete guide to setting up MongoDB Atlas or self-hosted MongoDB | ||
| MongoDB Atlas or self-hosted MongoDB | ||
| </Card> | ||
| </Step> | ||
|
|
||
| <Step title="Create Discord Webhooks"> | ||
| Webhooks are needed for the contact form and URL reporting functionality. | ||
| <Card title="Webhook Creation Guide" icon="webhook" href="/self-hosting/creating-contact-webhooks"> | ||
| Step-by-step instructions for creating Discord webhooks | ||
| The contact form and URL reporting deliver to Discord webhooks. | ||
|
|
||
| <Card title="Webhook Creation Guide" icon="webhook" href="/self-hosting/creating-discord-webhooks"> | ||
| Step-by-step webhook setup | ||
| </Card> | ||
| </Step> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Do not make Discord webhooks a deployment prerequisite.
CONTACT_WEBHOOK and URL_REPORT_WEBHOOK are optional and disable their respective features when unset.
self-hosting/introduction.mdx#L15-L32: describe webhooks as optional.self-hosting/deploy-koyeb.mdx#L9-L14: remove webhooks from required prerequisites.self-hosting/deploy-railway.mdx#L9-L14: remove webhooks from required prerequisites.self-hosting/deploy-render.mdx#L9-L14: remove webhooks from required prerequisites.self-hosting/deploy-vercel.mdx#L9-L14: remove webhooks from required prerequisites.
📍 Affects 5 files
self-hosting/introduction.mdx#L15-L32(this comment)self-hosting/deploy-koyeb.mdx#L9-L14self-hosting/deploy-railway.mdx#L9-L14self-hosting/deploy-render.mdx#L9-L14self-hosting/deploy-vercel.mdx#L9-L14
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@self-hosting/introduction.mdx` around lines 15 - 32, Describe Discord
webhooks as optional in self-hosting/introduction.mdx, noting that
CONTACT_WEBHOOK and URL_REPORT_WEBHOOK disable their respective features when
unset; remove webhook setup from the required prerequisites in
self-hosting/deploy-koyeb.mdx lines 9-14, self-hosting/deploy-railway.mdx lines
9-14, self-hosting/deploy-render.mdx lines 9-14, and
self-hosting/deploy-vercel.mdx lines 9-14.
Complete rebuild of the documentation, targeting the next spoo release.
Structure
Four tabs: Documentation (product guides), API Reference, Self-Hosting, and spoo-qr. Product guides and the API reference are now separate tracks.
Product guides (new)
Around 170 screenshots of the real product, captured in both themes as focused component crops.
API reference
The v1 spec is regenerated from the application schema (56 paths, up from 24 documented before): public stats and preview, bulk operations, url reads, emoji set, metadata parser, reports and contact, and the webhooks endpoints. Rate limits are documented per endpoint from the source of truth. Legacy v0 error shapes, validation rules, and status codes corrected.
Self-hosting
Fixes
Summary by CodeRabbit
APP_URL.