z4ps.uk
API

z4ps REST API

Create, list, update and delete short links, and pull click statistics, from your own code. Available on Bolt and above.

Authentication

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 is not on Bolt or above (including if a subscription lapses), every key on the account returns 403 Forbidden until you upgrade again; the keys themselves are not deleted.

Base URL

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.

Rate limits

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

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.

Entitlements

Every endpoint enforces the exact same plan rules as the dashboard: monthly link allowance, custom/short-slug quotas, editing rights, and analytics retention. There is no separate "API tier" — your plan behaves identically whether you use the website or the API. See the pricing page for current per-plan limits.

Endpoints

POST/api-v1.php

Create a short link.

Body parameters

destinationstring, required — the http(s) URL to redirect to
slugstring, optional — a custom slug (subject to your plan's length/quota rules); omit for a random slug
campaign_idinteger, optional — assign the link to one of your own campaigns at creation (Surge and above); omit or send null for no campaign
expires_atstring, optional — an ISO-8601 or YYYY-MM-DD HH:MM:SS timestamp in the future; the link stops redirecting once this passes. Free on every plan. Omit for no time-based expiry.
max_clicksinteger, optional — the link stops redirecting once it has been granted this many clicks. Free on every plan. Omit for no click-count limit.
utm_source, utm_medium, utm_campaign, utm_term, utm_contentstring, optional, free on every plan — 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.

Example request

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}'

Example response — 201 Created

{
  "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
  }
}
GET/api-v1.php

List your links, newest first.

Query parameters

pageinteger, optional, default 1
per_pageinteger, optional, default 20, max 100
formatstring, 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.

Example response — 200 OK

{
  "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

GET/api-v1.php?id=<id>

Fetch one of your links by id.

Example response — 200 OK

{ "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.

PATCH/api-v1.php?id=<id>

Update a link's destination. Editing destinations is a Bolt+ feature and is refused on a link that is frozen (over your plan's slug limit) — the link keeps redirecting either way, only editing is blocked.

Body parameters

destinationstring, required — the new http(s) URL
campaign_idinteger or null, optional — reassign the link to one of your own campaigns (Surge and above), or send null to unassign; omit this key entirely to leave the campaign untouched
expires_atstring or null, optional — a future timestamp, or null to remove the time-based expiry; omit this key entirely to leave it untouched
max_clicksinteger 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.

Example response — 200 OK

{ "link": { "id": 42, "destination": "https://example.com/new-page", "campaign_id": 7, "...": "..." } }
DELETE/api-v1.php?id=<id>

Delete a link and its click history. This cannot be undone.

Example response — 200 OK

{ "deleted": true, "id": 42 }
GET/api-v1.php?id=<id>&stats=1

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 plan, exactly like the dashboard.

Query parameters

formatstring, 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).

Example response — 200 OK

{
  "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 } ]
}
Every destination submitted through the API — on create and on update — goes through the same safety checks as the website: scheme validation, redirect-loop and SSRF protection, and a Web Risk lookup where configured. A rejected destination returns 422 Unprocessable Entity with the reason.

Link expiry

Unlike every other feature on this page, expiry is free on every plan — it's a safety/hygiene feature, not a premium one. 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.

UTM tagging

Free on every plan, like expiry above. 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.

Link rules (geo/device targeting & A/B split)

Available on Surge and above. 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.

GET/api-v1.php?id=<id>&rules=1

List a link's rules, ordered for evaluation.

Example response — 200 OK

{
  "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" }
  ]
}
POST/api-v1.php?id=<id>&rules=1

Create one rule (or one alternative of a split group).

Body parameters

condition_typestring, required — one of country, device, split
condition_valuestring — required for country (comma-separated 2-letter codes, e.g. FR,DE,BE) and device (one of desktop, mobile, tablet); omitted for split
destinationstring, required — the http(s) URL for this condition/alternative, checked through the same safety gate as a normal link
weightinteger 1-1000, split only — relative share of traffic for this alternative
positioninteger, optional — omit for a new rule/group (appended last); pass an existing group's position to add another weighted alternative to that split test

Example response — 201 Created

{ "rule": { "id": 6, "position": 1, "condition_type": "split", "...": "..." } }
POST/api-v1.php?id=<id>&rules=1

Reorder a link's rule groups, by sending reorder instead of a new rule's fields.

Body parameters

reorderarray of integers, required — rule ids in the desired new order; ids you own but omit keep their relative order, appended after the given ones

Example request

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]}'
PATCH/api-v1.php?id=<id>&rules=1&rule_id=<rid>

Update one rule's condition, destination, or (for a split alternative) weight. Body parameters are the same as create.

Example response — 200 OK

{ "rule": { "id": 6, "destination": "https://example.com/a2", "...": "..." } }
DELETE/api-v1.php?id=<id>&rules=1&rule_id=<rid>

Delete one rule (or one alternative of a split group).

Example response — 200 OK

{ "deleted": true, "id": 6 }