Create, list, update and delete short links, and pull click statistics, from your own code. Available to accounts with existing REST API access.
Every request must carry a bearer token in the Authorization header. Create a key from your dashboard's API keys page — the raw key is shown once, at creation, and cannot be retrieved again.
Authorization: Bearer z4ps_3a9f1c...
An unknown, revoked or expired key returns 401 Unauthorized. Revoking a key from the dashboard takes effect immediately — the very next request fails. If your account no longer has REST API access, every key on that account returns 403 Forbidden. The keys themselves are not deleted.
https://z4ps.uk/api-v1.php
There is a single endpoint file; the resource and action are selected by HTTP method and the id query parameter, as shown below. All request and response bodies are JSON.
Generous by design — a real integration will not hit these. Limits apply per key and per account (across all of an account's keys), whichever is hit first: 600 requests/hour per key, 1,200 requests/hour per account. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers. Exceeding the limit returns 429 Too Many Requests.
Errors are always {"error": "human-readable message"} with a matching HTTP status. Messages never leak internal details (stack traces, queries, file paths) — only what went wrong and, where useful, how to fix it.
The REST API applies the same account limits and permissions as the dashboard, including monthly link allowance, custom alias quotas, editing access and analytics retention.
Create a short link.
| destination | string, required — the http(s) URL to redirect to |
| slug | string, optional — a custom slug (subject to your account limits); omit for a random slug |
| campaign_id | integer, optional — assign the link to one of your own campaigns at creation (requires existing campaign access); omit or send null for no campaign |
| expires_at | string, optional — an ISO-8601 or YYYY-MM-DD HH:MM:SS timestamp in the future; the link stops redirecting once this passes. Omit for no time-based expiry. |
| max_clicks | integer, optional — the link stops redirecting once it has been granted this many clicks. Omit for no click-count limit. |
| utm_source, utm_medium, utm_campaign, utm_term, utm_content | string, optional — if any is present, destination is tagged with the given UTM parameters before shortening. Any existing query string on destination is preserved; existing utm_* values are replaced, never duplicated. Requires at least one non-empty value. |
curl -X POST https://z4ps.uk/api-v1.php \
-H "Authorization: Bearer z4ps_3a9f1c..." \
-H "Content-Type: application/json" \
-d '{"destination":"https://example.com/landing","slug":"launch","campaign_id":7,"max_clicks":500}'
{
"link": {
"id": 42,
"slug": "launch",
"destination": "https://example.com/landing",
"short_url": "https://z4ps.uk/launch",
"status": "active",
"protected": false,
"campaign_id": 7,
"expires_at": null,
"max_clicks": 500,
"granted_clicks": 0,
"expired": false,
"expired_at": null,
"expired_reason": null,
"created_at": "2026-08-01 10:15:02",
"updated_at": null
}
}
List your links, newest first.
| page | integer, optional, default 1 |
| per_page | integer, optional, default 20, max 100 |
| format | string, optional — json (default), csv, tsv, txt or md. A non-JSON value returns a file download of the current page of links (page/per_page still apply, so page through or raise per_page for everything). The same serialiser the dashboard uses: csv/tsv are a spreadsheet table (UTF-8 BOM included), txt is one short URL per line, md is a Markdown link list. An unrecognised value falls back to JSON. |
{
"links": [ { "id": 42, "slug": "launch", "...": "..." } ],
"pagination": { "page": 1, "per_page": 20, "total": 1, "total_pages": 1 }
}
Example CSV export: curl -H "Authorization: Bearer z4ps_..." "https://z4ps.uk/api-v1.php?format=csv&per_page=100" -o links.csv
Fetch one of your links by id.
{ "link": { "id": 42, "slug": "launch", "...": "..." } }
Returns 404 Not Found if the id does not exist or belongs to a different account — the two cases look identical, so no ownership is ever leaked.
Update a link's destination. Destination editing requires existing account access and is refused on a link that is frozen (over your alias limit) — the link keeps redirecting either way, only editing is blocked.
| destination | string, required — the new http(s) URL |
| campaign_id | integer or null, optional — reassign the link to one of your own campaigns (requires existing campaign access), or send null to unassign; omit this key entirely to leave the campaign untouched |
| expires_at | string or null, optional — a future timestamp, or null to remove the time-based expiry; omit this key entirely to leave it untouched |
| max_clicks | integer or null, optional — a new click limit, or null to remove it; omit this key entirely to leave it untouched. Lowering it below the clicks already granted expires the link immediately; raising it (or removing the limit) un-expires a link that only expired by clicks. |
{ "link": { "id": 42, "destination": "https://example.com/new-page", "campaign_id": 7, "...": "..." } }
Delete a link and its click history. This cannot be undone.
{ "deleted": true, "id": 42 }
Click statistics for one of your links: all-time totals plus a 30-day daily series. Retention (how far back detail is kept) matches your account limits, exactly like the dashboard.
| format | string, optional — json (default), csv or tsv. A non-JSON value downloads the daily series as a table (the scalar totals stay JSON-only, so text/Markdown do not apply here). |
{
"id": 42,
"slug": "launch",
"totals": { "total": 128, "humans": 110, "bots": 18, "unique_ish": 74 },
"daily": [ { "date": "2026-07-02", "total": 4, "humans": 4, "bots": 0 } ]
}
422 Unprocessable Entity with the reason.Set an expires_at timestamp and/or a max_clicks count on create or update; either or both may be set, and either can be cleared independently by sending null for that key on a PATCH. An expired link returns 410 Gone with a branded page and never reveals its destination — a deliberate, owner-initiated exception to the "a link never breaks" rule. granted_clicks in the response is the number of clicks actually let through the redirect (counted atomically, so a burst of concurrent requests against the last remaining click cannot overshoot max_clicks); the expired boolean and expired_at/expired_reason fields reflect current state.
Pass any of utm_source, utm_medium, utm_campaign, utm_term or utm_content on create and destination is tagged before shortening: any existing query string is kept, any existing utm_* values are overwritten (never duplicated), and values are percent-encoded. Omit all five to shorten destination unmodified, exactly as before.
Requires existing routing access. A link's rules run in order, first match wins: country and device rules match or don't; a split rule always matches once reached and picks a weighted alternative (sticky per visitor). Rules sharing the same position form one split group — create two rules with the same position to build an A/B test. If nothing matches, the link's own destination is used, exactly as if it had no rules.
List a link's rules, ordered for evaluation.
{
"rules": [
{ "id": 5, "position": 0, "condition_type": "country", "condition_value": "FR,DE,BE",
"destination": "https://example.com/eu", "weight": 100, "created_at": "2026-08-01 10:00:00" },
{ "id": 6, "position": 1, "condition_type": "split", "condition_value": null,
"destination": "https://example.com/a", "weight": 50, "created_at": "2026-08-01 10:01:00" },
{ "id": 7, "position": 1, "condition_type": "split", "condition_value": null,
"destination": "https://example.com/b", "weight": 50, "created_at": "2026-08-01 10:01:00" }
]
}
Create one rule (or one alternative of a split group).
| condition_type | string, required — one of country, device, split |
| condition_value | string — required for country (comma-separated 2-letter codes, e.g. FR,DE,BE) and device (one of desktop, mobile, tablet); omitted for split |
| destination | string, required — the http(s) URL for this condition/alternative, checked through the same safety gate as a normal link |
| weight | integer 1-1000, split only — relative share of traffic for this alternative |
| position | integer, optional — omit for a new rule/group (appended last); pass an existing group's position to add another weighted alternative to that split test |
{ "rule": { "id": 6, "position": 1, "condition_type": "split", "...": "..." } }
Reorder a link's rule groups, by sending reorder instead of a new rule's fields.
| reorder | array of integers, required — rule ids in the desired new order; ids you own but omit keep their relative order, appended after the given ones |
curl -X POST "https://z4ps.uk/api-v1.php?id=42&rules=1" \
-H "Authorization: Bearer z4ps_3a9f1c..." \
-H "Content-Type: application/json" \
-d '{"reorder":[6,7,5]}'
Update one rule's condition, destination, or (for a split alternative) weight. Body parameters are the same as create.
{ "rule": { "id": 6, "destination": "https://example.com/a2", "...": "..." } }
Delete one rule (or one alternative of a split group).
{ "deleted": true, "id": 6 }