-
-
Notifications
You must be signed in to change notification settings - Fork 2
docs: full documentation revamp for the next release #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
f4ce023
docs(api): regenerate v1 OpenAPI spec from the deployed release
Zingzy 18f6c04
docs: add custom domains and device auth guides
Zingzy a1cd739
docs: correct rate limits to match production behavior
Zingzy d3fb1d0
docs(self-hosting): replace HOST_URI with APP_URL
Zingzy 3c0c7d7
docs(v0): fix legacy API error shapes and validation rules
Zingzy 44abb38
docs: fix remaining broken links found by the link checker
Zingzy c05ea78
docs: drop key management and device auth from the reference nav
Zingzy 92cad7e
docs: make the custom domains page product-first with real UI shots
Zingzy 1502318
docs: restructure into four tabs and split cloud deployment per provider
Zingzy a1dc894
docs(api): regenerate v1 spec from the upcoming release
Zingzy fd56383
docs: add product guides for links, domains, analytics, webhooks
Zingzy 9360194
docs: wire full navigation and refresh core guides
Zingzy 28ebe12
docs: add mesh gradient background and trim self-hosting menus
Zingzy edf0cf5
docs: rename existing captures for theme-variant support
Zingzy a2f036e
docs: remove the mesh gradient background
Zingzy 46a4bdc
docs: drop the account section
Zingzy 90138cf
docs: document the dashboard, links table, and analytics builder
Zingzy b121b30
docs: add focused component screenshots across all guides
Zingzy 95aaab6
docs: fill remaining screenshots and add per-step composer frames
Zingzy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|
|
||
| <Frame> | ||
| <img className="block dark:hidden" src="/images/dashboard/analytics-light.png" alt="Analytics board with stat tiles, clicks over time, world map, and dimension widgets" /> | ||
| <img className="hidden dark:block" src="/images/dashboard/analytics-dark.png" alt="Analytics board with stat tiles, clicks over time, world map, and dimension widgets" /> | ||
| </Frame> | ||
|
|
||
| ## 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. | ||
|
|
||
| <Frame> | ||
| <img className="block dark:hidden" src="/images/dashboard/analytics-widget-expanded-light.png" alt="A widget expanded to fullscreen with chart and table toggles" /> | ||
| <img className="hidden dark:block" src="/images/dashboard/analytics-widget-expanded-dark.png" alt="A widget expanded to fullscreen with chart and table toggles" /> | ||
| </Frame> | ||
|
|
||
| ## 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. | ||
|
|
||
| <Frame> | ||
| <img className="block dark:hidden" src="/images/dashboard/analytics-filtered-light.png" alt="Board filtered by a referrer, with a clear-filter chip in the toolbar" /> | ||
| <img className="hidden dark:block" src="/images/dashboard/analytics-filtered-dark.png" alt="Board filtered by a referrer, with a clear-filter chip in the toolbar" /> | ||
| </Frame> | ||
|
|
||
| ## Edit the layout | ||
|
|
||
| <Steps> | ||
| <Step title="Enter edit mode"> | ||
| Click **Edit layout** in the toolbar. The board switches to a dotted grid; widgets get drag handles, resize corners, and a remove button. | ||
|
|
||
| <Frame> | ||
| <img className="block dark:hidden" src="/images/dashboard/analytics-edit-mode-light.png" alt="Analytics board in edit mode with a dotted grid and per-widget controls" /> | ||
| <img className="hidden dark:block" src="/images/dashboard/analytics-edit-mode-dark.png" alt="Analytics board in edit mode with a dotted grid and per-widget controls" /> | ||
| </Frame> | ||
| </Step> | ||
| <Step title="Add or remove widgets"> | ||
| **Add widget** lists every widget type with a checkbox; removed defaults can be re-added the same way. | ||
|
|
||
| <Frame> | ||
| <img className="block dark:hidden" src="/images/dashboard/analytics-add-widget-light.png" alt="Add widget menu listing widget types with checkboxes" /> | ||
| <img className="hidden dark:block" src="/images/dashboard/analytics-add-widget-dark.png" alt="Add widget menu listing widget types with checkboxes" /> | ||
| </Frame> | ||
| </Step> | ||
| <Step title="Drag and resize"> | ||
| 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. | ||
|
|
||
| <Frame> | ||
| <img className="block dark:hidden" src="/images/dashboard/analytics-widget-resized-light.png" alt="A widget being resized on the edit-mode grid" /> | ||
| <img className="hidden dark:block" src="/images/dashboard/analytics-widget-resized-dark.png" alt="A widget being resized on the edit-mode grid" /> | ||
| </Frame> | ||
| </Step> | ||
| <Step title="Done"> | ||
| Click **Done** to save. The layout is stored on your account and applied on every device you sign in from. | ||
| </Step> | ||
| </Steps> | ||
|
|
||
| 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. | ||
|
|
||
| <Frame> | ||
| <img className="block dark:hidden" src="/images/dashboard/analytics-custom-chart-light.png" alt="Custom chart builder with axis, chart type, and scope controls" /> | ||
| <img className="hidden dark:block" src="/images/dashboard/analytics-custom-chart-dark.png" alt="Custom chart builder with axis, chart type, and scope controls" /> | ||
| </Frame> | ||
|
|
||
| | 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 | | ||
|
|
||
| <Frame> | ||
| <img className="block dark:hidden" src="/images/dashboard/analytics-custom-chart-configured-light.png" alt="Custom chart builder with a configured donut chart in the live preview" /> | ||
| <img className="hidden dark:block" src="/images/dashboard/analytics-custom-chart-configured-dark.png" alt="Custom chart builder with a configured donut chart in the live preview" /> | ||
| </Frame> | ||
|
|
||
| <Tip> | ||
| 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. | ||
| </Tip> | ||
|
|
||
| ### 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. | ||
|
|
||
| <Frame> | ||
| <img className="block dark:hidden" src="/images/dashboard/analytics-custom-widget-added-light.png" alt="Custom widget selected on the grid with the floating inspector bar" /> | ||
| <img className="hidden dark:block" src="/images/dashboard/analytics-custom-widget-added-dark.png" alt="Custom widget selected on the grid with the floating inspector bar" /> | ||
| </Frame> | ||
|
|
||
| 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. | ||
|
|
||
| <Frame> | ||
| <img className="block dark:hidden" src="/images/dashboard/analytics-pinned-widget-light.png" alt="Pinned per-link widget on the board with an alias scope chip" /> | ||
| <img className="hidden dark:block" src="/images/dashboard/analytics-pinned-widget-dark.png" alt="Pinned per-link widget on the board with an alias scope chip" /> | ||
| </Frame> | ||
|
|
||
| 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 | | ||
|
|
||
| <Frame> | ||
| <img className="block dark:hidden" src="/images/dashboard/analytics-layout-menu-light.png" alt="Layout overflow menu with export, import, and reset options" /> | ||
| <img className="hidden dark:block" src="/images/dashboard/analytics-layout-menu-dark.png" alt="Layout overflow menu with export, import, and reset options" /> | ||
| </Frame> | ||
|
|
||
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|
|
||
| <CodeGroup> | ||
|
|
||
| ```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"]) | ||
| ``` | ||
|
|
||
| </CodeGroup> | ||
|
|
||
| <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> | ||
|
|
||
| ## 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | | ||
|
|
||
| <Note> | ||
| `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. | ||
| </Note> | ||
|
|
||
| ## 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: | ||
|
|
||
| <CodeGroup> | ||
|
|
||
| ```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) | ||
| ``` | ||
|
|
||
| </CodeGroup> | ||
|
|
||
| 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. |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Do not describe
short_codefiltering as silently ignored.The v1 contract says
filters.short_codeis not allowed withscope=anon; “ignored” tells clients an invalid filter will succeed. Document it as unsupported while retaining the top-levelshort_codeexplanation.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents