Skip to content
Closed
Show file tree
Hide file tree
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 Jul 24, 2026
18f6c04
docs: add custom domains and device auth guides
Zingzy Jul 24, 2026
a1cd739
docs: correct rate limits to match production behavior
Zingzy Jul 24, 2026
d3fb1d0
docs(self-hosting): replace HOST_URI with APP_URL
Zingzy Jul 24, 2026
3c0c7d7
docs(v0): fix legacy API error shapes and validation rules
Zingzy Jul 24, 2026
44abb38
docs: fix remaining broken links found by the link checker
Zingzy Jul 24, 2026
c05ea78
docs: drop key management and device auth from the reference nav
Zingzy Jul 24, 2026
92cad7e
docs: make the custom domains page product-first with real UI shots
Zingzy Jul 24, 2026
1502318
docs: restructure into four tabs and split cloud deployment per provider
Zingzy Jul 24, 2026
a1dc894
docs(api): regenerate v1 spec from the upcoming release
Zingzy Jul 24, 2026
fd56383
docs: add product guides for links, domains, analytics, webhooks
Zingzy Jul 24, 2026
9360194
docs: wire full navigation and refresh core guides
Zingzy Jul 24, 2026
28ebe12
docs: add mesh gradient background and trim self-hosting menus
Zingzy Jul 24, 2026
edf0cf5
docs: rename existing captures for theme-variant support
Zingzy Jul 24, 2026
a2f036e
docs: remove the mesh gradient background
Zingzy Jul 24, 2026
46a4bdc
docs: drop the account section
Zingzy Jul 24, 2026
90138cf
docs: document the dashboard, links table, and analytics builder
Zingzy Jul 24, 2026
b121b30
docs: add focused component screenshots across all guides
Zingzy Jul 24, 2026
95aaab6
docs: fill remaining screenshots and add per-step composer frames
Zingzy Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions analytics/custom-dashboards.mdx
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.
101 changes: 101 additions & 0 deletions analytics/dimensions.mdx
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>
Comment on lines +63 to +65

Copy link
Copy Markdown

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_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.

Suggested change
<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.


## 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.
86 changes: 86 additions & 0 deletions analytics/exports.mdx
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.
Loading