pik.li pik.li
Version 1 · stable

pik.li API

Create, edit and measure your short links from your own code. A REST API with JSON in and out, one key per integration, and every endpoint documented here with a request and a real answer.

Get an API key OpenAPI 3.1
Base URLhttps://pik.li/api/v1

Introduction

The pik.li API lets your own software do what the dashboard does: create and edit short links, read their statistics, list your tags and domains, and manage webhooks. It works over HTTPS and every path starts with the base URL shown above.

  • Send request bodies as JSON with Content-Type: application/json; parameters of GET requests go in the query string. Answers are always JSON in UTF-8, except the QR code, which is an image.
  • The version is part of the path (/api/v1) and every answer carries the header X-Api-Version: 1. New fields can appear in v1 answers over time: let your code ignore the ones it does not know.
  • Dates and times are ISO 8601 strings with their offset (2026-09-23T10:15:42.118+02:00); dates alone look like 2026-09-23.
  • Identifiers are integers. A link can also be found from its short URL with GET /links/lookup.

Quick start

  1. Create a key in the dashboard (see Create a key) and keep it in an environment variable.
  2. Check that it works: GET /me answers with your account and your plan.
  3. Create your first short link with POST /links.
Terminal
export PIKLI_KEY="pk_live_…"

curl https://pik.li/api/v1/me \
  -H "Authorization: Bearer $PIKLI_KEY"

curl -X POST https://pik.li/api/v1/links \
  -H "Authorization: Bearer $PIKLI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/a/very/long/page"}'

Authentication

Every call except GET /ping needs an API key, sent in the Authorization header as a bearer token.

Header
Authorization: Bearer pk_live_3kZ9vQeX1mT0bR7yNw2aLp4sUc8dHf6J

The X-API-Key header works as well. The api_key query parameter is still accepted for older clients, but avoid it: addresses end up in logs and browser histories.

Keys start with pk_live_ followed by 32 characters. A key sees exactly what its account sees — its links, domains, tags, statistics and webhooks — and all the keys of an account can do the same things: there are no read-only keys.

Treat a key like a password. Keep it on your server, never in code that runs in a browser or in an app you hand out. If one leaks, revoke it and create a new one: it takes ten seconds.

Create a key

  1. Sign in and open API and keys in the dashboard.
  2. Give the key a name that tells you where it is used (for example CRM production) and press Generate key.
  3. Copy the key straight away: the dashboard shows it only once.
  4. You can keep up to 10 active keys. For each one the page shows when it was last used and how many requests it made, and lets you revoke it: from the next call a revoked key gets 401 unauthorized.

Who can use the API

The API comes with the paid plans (Premium and Business). The Base plan does not include it, unless the pik.li staff switches it on for the free plan or for your account. In every case the account needs a confirmed e-mail address and must not be blocked or suspended.

When one of these conditions is missing the API answers api_disabled, email_unconfirmed or account_blocked.

Base

Only if the staff enables it
Requests per minute
60
New links
3 a day
Links per bulk call
10
Statistics kept for
90 days
Webhooks
No

Premium

API included
Requests per minute
600
New links
2,500 a month
Links per bulk call
100
Statistics kept for
730 days
Webhooks
Yes

Business

API included
Requests per minute
3,000
New links
10,000 a month
Links per bulk call
100
Statistics kept for
1,095 days
Webhooks
Yes

These are the standard numbers of each plan, read live from the pricing settings. Your own numbers, with any change the staff made to your account, come back from GET /limits and GET /me.

Rate limits

Each key can make a number of requests per minute that depends on the plan (see the table above). The count starts again at the beginning of every minute.

X-RateLimit-Limit
requests allowed per minute to this key
X-RateLimit-Remaining
requests left in the current minute
X-RateLimit-Reset
when the count starts again, as a Unix time in seconds
Retry-After
only on a 429 answer: how many seconds to wait

Over the limit, the API answers 429 with the error rate_limited and a Retry-After header. Wait that long and carry on: retrying straight away only eats into the next minute.

Response
HTTP/1.1 429 Too Many Requests
Content-Type: application/json; charset=utf-8
Retry-After: 18
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1790151360

{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Check the X-RateLimit-* headers and retry after the window resets.",
    "details": { "limit": 600, "period_seconds": 60, "resets_at": "2026-09-23T08:16:00Z" },
    "docs_url": "https://pik.li/help#error-rate_limited"
  },
  "request_id": "2b8e51d0-7a4c-4f1e-9d3b-5c6a0e8f7d21"
}
  • Calls without a key are limited to 30 a minute for each IP address (GET /ping is not counted).
  • On top of that, every IP address has a ceiling of 600 requests a minute to pik.li as a whole, whatever the plan.

Errors

A request that fails always gets an answer of the same shape, whatever the endpoint, with an HTTP status that matches the problem.

Response
{
  "error": {
    "code": "quota_exceeded",
    "message": "Link quota reached: 2500 links per month on your plan.",
    "details": { "limit": 2500, "used": 2500, "period": "month", "resets_at": "2026-09-30T22:00:00Z" },
    "docs_url": "https://pik.li/help#error-quota_exceeded"
  },
  "request_id": "6f1c2a9e-4b0d-4c55-9a51-8f2f0d7c1e3b"
}
code
Stable identifier, meant for your code. It never changes and is never translated.
message
A sentence for people, in the language of the request (see Languages).
details
Structured data when it helps: the invalid fields, the allowed values, the numbers of a quota. Not always present.
docs_url
The link to the explanation of this code, on this page.
request_id
The identifier of the request, also sent in the X-Request-Id header. Quote it when you write to support.

Every endpoint that needs a key can also answer unauthorized account_blocked email_unconfirmed api_disabled rate_limited: they are not repeated under each endpoint.

unauthorized401
The key is missing, wrong or revoked. Check the Authorization header and that the key still appears as active in the dashboard.
account_blocked403
The account is blocked, suspended or deactivated. The API stays closed until the account is active again.
email_unconfirmed403
The e-mail address of the account has not been confirmed yet. Follow the link in the confirmation e-mail, then try again.
api_disabled403
The API is not included in the plan of this account, or it has been switched off for it. See Who can use the API.
forbidden403
The key cannot perform this action. reserved
feature_required402
The plan does not include the feature this endpoint needs. details.feature names it and details.plan the first plan that has it.
quota_exceeded402
The link quota of the day or month is used up. details has limit, used, period and resets_at, when new links become possible again.
not_found404
The resource does not exist, has been deleted or belongs to another account.
conflict409
The request clashes with the current state of the resource. reserved
invalid422
Some data did not pass validation: details.fields lists the problems field by field, details.messages as ready-made sentences.
domain_not_allowed422
Your account cannot use this domain. GET /domains lists the ones it can.
bad_request400
A required parameter is missing; details.parameter names it.
invalid_json400
The body is not valid JSON. Check the quotes and the Content-Type header.
invalid_parameter400
A parameter has a value the endpoint does not know. The message names the parameter and details.allowed, when present, lists the accepted values.
bulk_empty400
The links array of a bulk call is empty or missing.
bulk_too_many400
Too many links in one bulk call. details.max is the limit of your plan: split the list into several calls.
idempotency_key_invalid400
The Idempotency-Key header is longer than 128 characters.
rate_limited429
Too many requests in this minute. Wait the seconds in Retry-After, then carry on.

A 500 answer means something broke on our side and there is nothing to fix in your request. Try again a little later; if it keeps happening, write to support with the value of the X-Request-Id header.

Pagination

Lists that can grow long are split into pages: GET /links, GET /links/active and GET /links/:id/clicks.

  • page: the page you want, starting from 1.
  • per_page: how many items per page, from 1 to 100 (default 50). limit is accepted as a synonym.

The answer carries a pagination object, and the total is also in the X-Total-Count header. Keep asking for next_page until it is null.

Response
{
  "links": [],
  "pagination": { "page": 2, "per_page": 50, "total": 137, "total_pages": 3, "next_page": 3, "prev_page": 1 }
}

Idempotency

POST /links and POST /links/bulk accept an Idempotency-Key header: any text up to 128 characters that you choose, one per operation — an order number, a UUID.

When the same key arrives again from the same API key within 24 hours, pik.li creates nothing: it sends back the first answer, with the header Idempotent-Replayed: true. A request repeated after a timeout never makes a duplicate.

Same request, same key, within 24 hours
HTTP/1.1 201 Created
Idempotent-Replayed: true
Content-Type: application/json; charset=utf-8

Answers with a 5xx status are not remembered, so repeating the request with the same key runs it again. A key that is too long gets idempotency_key_invalid.

Languages

Error messages are written in nine languages: en it zh ar ru fr de es pt-BR. The API chooses from the locale parameter, then from the Accept-Language header, then from the language of your account, and falls back to English. Only the message changes: codes and field names stay the same, and so does docs_url, which opens this page in the same language.

Terminal
curl "https://pik.li/api/v1/links?status=nope" \
  -H "Authorization: Bearer $PIKLI_KEY" \
  -H "Accept-Language: it"

{
  "error": {
    "code": "invalid_parameter",
    "message": "Valore non valido per il parametro status.",
    "details": { "allowed": ["all", "active", "disabled", "review", "expired"] },
    "docs_url": "https://pik.li/help?locale=it#error-invalid_parameter"
  },
  "request_id": "0d5c9b7e-1f2a-4e3b-8c6d-7a9e0f1b2c3d"
}

Statistics windows

The statistics endpoints read a window of time and compare it with the window just before it, of the same length: that is where previous and delta_pct come from.

period
24h, 7d, 30d, 90d, 1y or all (all the history your plan keeps). A number of days also works: period=45, or the older days=45. The default is 7d for the account, 30d for one link and all for the list of clicks.
from, to
Instead of period: a date (2026-09-01) or an ISO 8601 time. to defaults to now, from to 30 days before to; a date in to counts up to the end of that day.
interval
hour or day, the step of the time series. Windows up to 48 hours are hourly by default, longer ones daily; an hourly series covers at most 7 days.
window.clamped
A window never reaches further back than the history your plan keeps (retention_days). When it has been cut, window.clamped is true.
bots
Clicks from bots are left out. Add bots=1 to count them too; one link's statistics also report them apart in period.bots.

Endpoints

Every endpoint with its parameters, a request you can paste in a terminal and a real answer, shortened where a list would be long. The examples read the key from $PIKLI_KEY, as in the quick start.

Service

Is the API up, and how much of your plan is left.

GET /ping No key needed

Check that the API is up

Needs no key: meant for health checks. Returns the time of the server and the addresses of this documentation and of the OpenAPI file.

Request
curl https://pik.li/api/v1/ping
Response · 200
{
  "ok": true,
  "service": "pik.li",
  "version": "v1",
  "time": "2026-09-23T08:15:42Z",
  "docs": "https://pik.li/help",
  "openapi": "https://pik.li/openapi.json"
}
Errors None: this endpoint needs no key.
GET /limits

What is left of your quotas

Links still available today or this month, tracked clicks, the rate-limit window in progress, keys and custom domains in use, and the maximum size of a bulk call. Every number includes the changes the staff may have made to your account.

Request
curl https://pik.li/api/v1/limits \
  -H "Authorization: Bearer $PIKLI_KEY"
Response · 200
{
  "plan": "premium",
  "plan_name": "Premium",
  "links": {
    "period": "month", "limit": 2500, "used": 412, "remaining": 2088,
    "resets_at": "2026-09-30T22:00:00Z",
    "today": 9, "this_month": 412, "per_day": 0, "per_month": 2500
  },
  "clicks": { "limit": 150000, "used": 38120, "remaining": 111880, "resets_at": "2026-09-30T22:00:00Z" },
  "rate_limit": { "limit": 600, "remaining": 597, "period_seconds": 60, "resets_at": "2026-09-23T08:16:00Z" },
  "api_keys": { "used": 2, "limit": 10 },
  "custom_domains": { "used": 1, "limit": 3 },
  "bulk": { "max_items": 100 },
  "retention_days": 730,
  "link_ttl_days": 0,
  "features": ["qr", "analytics_full", "tags", "export", "custom_slug", "password", "expiry", "utm", "bulk", "api", "webhooks"]
}
Errors Only the ones every endpoint can return.

Account

Who is calling: the account that owns the key.

GET /me

Your account and the key in use

Profile, plan and its expiry, features, limits and usage, and the key that made the call, with its request counter.

Request
curl https://pik.li/api/v1/me \
  -H "Authorization: Bearer $PIKLI_KEY"
Response · 200
{
  "id": 1042,
  "username": "anna",
  "email": "[email protected]",
  "name": "Anna Rossi",
  "status": "active",
  "confirmed": true,
  "locale": "it",
  "plan": "premium",
  "plan_name": "Premium",
  "interval": "monthly",
  "plan_expires_at": "2026-10-14T23:59:59.000+02:00",
  "api_enabled": true,
  "features": ["qr", "analytics_full", "tags", "export", "custom_slug", "password", "expiry", "utm", "bulk", "api", "webhooks"],
  "limits": {
    "links_per_day": 0, "links_per_month": 2500, "clicks_per_month": 150000,
    "retention_days": 730, "link_ttl_days": 0, "custom_domains": 3, "api_rpm": 600
  },
  "usage": {
    "links_today": 9, "links_this_month": 412, "clicks_this_month": 38120,
    "links_quota_used": 412, "links_quota_limit": 2500, "links_total": 3187
  },
  "api_key": {
    "id": 55, "name": "CRM production", "prefix": "pk_live_Xq3v",
    "created_at": "2026-09-02T16:40:10.311+02:00", "last_used_at": "2026-09-23T10:15:42.020+02:00", "requests_count": 18230
  },
  "created_at": "2026-03-11T09:12:47.664+01:00"
}
Errors Only the ones every endpoint can return.

Account statistics

The numbers of all your links together, with the filters of the dashboard: window, domain, tag, bots. See Statistics windows for the common parameters.

GET /stats

Account statistics in one call

Totals of the account, the window compared with the one before, the time series, the first 10 referrers and countries, the first 6 devices, browsers and operating systems, and the 10 most clicked links. The other /stats endpoints return each part alone, with longer lists.

Parameters
  • period string query string

    The window: 24h, 7d (default), 30d, 90d, 1y, all. See Statistics windows.

  • from string query string

    Start of the window, instead of period: a date or an ISO 8601 time.

  • to string query string

    End of the window (now by default).

  • interval string query string

    hour or day, the step of the time series.

  • domain string query string

    Only this domain, by hostname (for example lnkz.li). It must be one of yours: see GET /domains.

  • tag string query string

    Only the links with this tag.

  • bots string query string

    1 to count clicks from bots too.

Request
curl "https://pik.li/api/v1/stats?period=7d" \
  -H "Authorization: Bearer $PIKLI_KEY"
Response · 200
{
  "window": {
    "from": "2026-09-17T00:00:00+02:00",
    "to": "2026-09-23T10:15:42+02:00",
    "days": 7,
    "interval": "day",
    "period": "7d",
    "clamped": false,
    "retention_days": 730,
    "previous": { "from": "2026-09-10T00:00:00+02:00", "to": "2026-09-17T00:00:00+02:00" }
  },
  "filters": { "bots": false },
  "totals": {
    "links": 3187, "active_links": 2954, "clicks": 402118, "unique_clicks": 288930,
    "clicks_today": 1204, "links_active_24h": 214, "links_created_in_window": 61
  },
  "period": { "clicks": 21480, "uniques": 15522, "links_clicked": 690, "clicks_per_link": 31.1 },
  "previous": { "clicks": 19870, "uniques": 14410, "links_clicked": 655, "clicks_per_link": 30.3, "delta_pct": 8.1, "uniques_delta_pct": 7.7 },
  "timeseries": {
    "interval": "day",
    "points": [
      { "at": "2026-09-17", "clicks": 2890, "uniques": 2104 },
      { "at": "2026-09-18", "clicks": 3120, "uniques": 2240 }
    ]
  },
  "referrers": [{ "referrer": "instagram.com", "clicks": 5210, "share": 24.3 }],
  "direct": 8740,
  "countries": [{ "country": "IT", "clicks": 12877, "share": 59.9 }],
  "devices": [{ "device": "mobile", "clicks": 14002, "share": 65.2 }],
  "browsers": [{ "browser": "Chrome", "clicks": 9406, "share": 43.8 }],
  "os": [{ "os": "iOS", "clicks": 7340, "share": 34.2 }],
  "top_links": [{
    "id": 4821,
    "short_url": "https://lnkz.li/spring-sale",
    "slug": "spring-sale",
    "domain": "lnkz.li",
    "target_url": "https://example.com/shop/spring-sale",
    "target_host": "example.com",
    "title": "Spring sale",
    "description": null,
    "tags": ["promo", "newsletter"],
    "status": "active",
    "moderation": "allowed",
    "active": true,
    "expired": false,
    "password_protected": false,
    "clicks": 1284,
    "unique_clicks": 902,
    "last_clicked_at": "2026-09-23T09:58:12.431+02:00",
    "expires_at": null,
    "max_clicks": null,
    "disabled_reason": null,
    "utm": { "source": "newsletter", "medium": "email", "campaign": "spring" },
    "preview_url": "https://lnkz.li/spring-sale+",
    "qr_url": "https://pik.li/api/v1/links/4821/qr",
    "created_at": "2026-09-01T08:30:05.117+02:00",
    "updated_at": "2026-09-01T08:30:05.117+02:00",
    "clicks_in_window": 311,
    "uniques_in_window": 240
  }]
}
GET /stats/timeseries

Clicks over time

Clicks and unique visitors per hour or per day, with the total of the window before and the change in percent.

Parameters
  • period string query string

    The window: 24h, 7d (default), 30d, 90d, 1y, all. See Statistics windows.

  • from string query string

    Start of the window, instead of period: a date or an ISO 8601 time.

  • to string query string

    End of the window (now by default).

  • interval string query string

    hour or day, the step of the time series.

  • domain string query string

    Only this domain, by hostname (for example lnkz.li). It must be one of yours: see GET /domains.

  • tag string query string

    Only the links with this tag.

  • bots string query string

    1 to count clicks from bots too.

Request
curl "https://pik.li/api/v1/stats/timeseries?period=24h" \
  -H "Authorization: Bearer $PIKLI_KEY"
Response · 200
{
  "window": {
    "from": "2026-09-22T10:15:42+02:00",
    "to": "2026-09-23T10:15:42+02:00",
    "days": 1,
    "interval": "hour",
    "period": "24h",
    "clamped": false,
    "retention_days": 730,
    "previous": { "from": "2026-09-21T10:15:42+02:00", "to": "2026-09-22T10:15:42+02:00" }
  },
  "filters": { "bots": false },
  "interval": "hour",
  "clicks": 3308,
  "previous_clicks": 2977,
  "delta_pct": 11.1,
  "points": [
    { "at": "2026-09-22T08:00:00Z", "clicks": 131, "uniques": 97 },
    { "at": "2026-09-22T09:00:00Z", "clicks": 158, "uniques": 120 }
  ]
}
GET /stats/referrers

Where the clicks come from

The sites that sent the most clicks, each with its share of the total, and the clicks with no referrer (direct).

Parameters
  • period string query string

    The window: 24h, 7d (default), 30d, 90d, 1y, all. See Statistics windows.

  • from string query string

    Start of the window, instead of period: a date or an ISO 8601 time.

  • to string query string

    End of the window (now by default).

  • domain string query string

    Only this domain, by hostname (for example lnkz.li). It must be one of yours: see GET /domains.

  • tag string query string

    Only the links with this tag.

  • bots string query string

    1 to count clicks from bots too.

  • limit integer query string

    How many rows per list, from 1 to 50 (default 10).

Request
curl "https://pik.li/api/v1/stats/referrers?period=30d&limit=5" \
  -H "Authorization: Bearer $PIKLI_KEY"
Response · 200
{
  "window": {
    "from": "2026-09-17T00:00:00+02:00",
    "to": "2026-09-23T10:15:42+02:00",
    "days": 7,
    "interval": "day",
    "period": "7d",
    "clamped": false,
    "retention_days": 730,
    "previous": { "from": "2026-09-10T00:00:00+02:00", "to": "2026-09-17T00:00:00+02:00" }
  },
  "filters": { "bots": false },
  "clicks": 21480,
  "direct": 8740,
  "referrers": [
    { "referrer": "instagram.com", "clicks": 5210, "share": 24.3 },
    { "referrer": "google.com", "clicks": 3011, "share": 14.0 }
  ]
}
GET /stats/countries

Countries and cities

Countries as ISO codes, with their share; cities comes with plans that include full statistics.

Parameters
  • period string query string

    The window: 24h, 7d (default), 30d, 90d, 1y, all. See Statistics windows.

  • from string query string

    Start of the window, instead of period: a date or an ISO 8601 time.

  • to string query string

    End of the window (now by default).

  • domain string query string

    Only this domain, by hostname (for example lnkz.li). It must be one of yours: see GET /domains.

  • tag string query string

    Only the links with this tag.

  • bots string query string

    1 to count clicks from bots too.

  • limit integer query string

    How many rows per list, from 1 to 50 (default 10).

Request
curl "https://pik.li/api/v1/stats/countries?period=30d" \
  -H "Authorization: Bearer $PIKLI_KEY"
Response · 200
{
  "window": {
    "from": "2026-09-17T00:00:00+02:00",
    "to": "2026-09-23T10:15:42+02:00",
    "days": 7,
    "interval": "day",
    "period": "7d",
    "clamped": false,
    "retention_days": 730,
    "previous": { "from": "2026-09-10T00:00:00+02:00", "to": "2026-09-17T00:00:00+02:00" }
  },
  "filters": { "bots": false },
  "clicks": 21480,
  "countries": [
    { "country": "IT", "clicks": 12877, "share": 59.9 },
    { "country": "DE", "clicks": 2630, "share": 12.2 }
  ],
  "cities": [
    { "city": "Milano", "clicks": 3120, "share": 14.5 },
    { "city": "Roma", "clicks": 2210, "share": 10.3 }
  ]
}
GET /stats/devices

Devices, browsers, systems and languages

Four lists in one call, each with the clicks and the share of the total.

Parameters
  • period string query string

    The window: 24h, 7d (default), 30d, 90d, 1y, all. See Statistics windows.

  • from string query string

    Start of the window, instead of period: a date or an ISO 8601 time.

  • to string query string

    End of the window (now by default).

  • domain string query string

    Only this domain, by hostname (for example lnkz.li). It must be one of yours: see GET /domains.

  • tag string query string

    Only the links with this tag.

  • bots string query string

    1 to count clicks from bots too.

  • limit integer query string

    How many rows per list, from 1 to 50 (default 10).

Request
curl "https://pik.li/api/v1/stats/devices?period=30d" \
  -H "Authorization: Bearer $PIKLI_KEY"
Response · 200
{
  "window": {
    "from": "2026-09-17T00:00:00+02:00",
    "to": "2026-09-23T10:15:42+02:00",
    "days": 7,
    "interval": "day",
    "period": "7d",
    "clamped": false,
    "retention_days": 730,
    "previous": { "from": "2026-09-10T00:00:00+02:00", "to": "2026-09-17T00:00:00+02:00" }
  },
  "filters": { "bots": false },
  "clicks": 21480,
  "devices": [{ "device": "mobile", "clicks": 14002, "share": 65.2 }, { "device": "desktop", "clicks": 7100, "share": 33.1 }],
  "browsers": [{ "browser": "Chrome", "clicks": 9406, "share": 43.8 }],
  "os": [{ "os": "iOS", "clicks": 7340, "share": 34.2 }],
  "languages": [{ "language": "it", "clicks": 12690, "share": 59.1 }]
}
GET /stats/top

The most clicked links

The links with the most clicks in the window, each with clicks_in_window and uniques_in_window.

Parameters
  • period string query string

    The window: 24h, 7d (default), 30d, 90d, 1y, all. See Statistics windows.

  • from string query string

    Start of the window, instead of period: a date or an ISO 8601 time.

  • to string query string

    End of the window (now by default).

  • domain string query string

    Only this domain, by hostname (for example lnkz.li). It must be one of yours: see GET /domains.

  • tag string query string

    Only the links with this tag.

  • bots string query string

    1 to count clicks from bots too.

  • limit integer query string

    How many rows per list, from 1 to 50 (default 10).

Request
curl "https://pik.li/api/v1/stats/top?period=7d&limit=10" \
  -H "Authorization: Bearer $PIKLI_KEY"
Response · 200
{
  "window": {
    "from": "2026-09-17T00:00:00+02:00",
    "to": "2026-09-23T10:15:42+02:00",
    "days": 7,
    "interval": "day",
    "period": "7d",
    "clamped": false,
    "retention_days": 730,
    "previous": { "from": "2026-09-10T00:00:00+02:00", "to": "2026-09-17T00:00:00+02:00" }
  },
  "filters": { "bots": false },
  "clicks": 21480,
  "links": [{
    "id": 4821,
    "short_url": "https://lnkz.li/spring-sale",
    "slug": "spring-sale",
    "domain": "lnkz.li",
    "target_url": "https://example.com/shop/spring-sale",
    "target_host": "example.com",
    "title": "Spring sale",
    "description": null,
    "tags": ["promo", "newsletter"],
    "status": "active",
    "moderation": "allowed",
    "active": true,
    "expired": false,
    "password_protected": false,
    "clicks": 1284,
    "unique_clicks": 902,
    "last_clicked_at": "2026-09-23T09:58:12.431+02:00",
    "expires_at": null,
    "max_clicks": null,
    "disabled_reason": null,
    "utm": { "source": "newsletter", "medium": "email", "campaign": "spring" },
    "preview_url": "https://lnkz.li/spring-sale+",
    "qr_url": "https://pik.li/api/v1/links/4821/qr",
    "created_at": "2026-09-01T08:30:05.117+02:00",
    "updated_at": "2026-09-01T08:30:05.117+02:00",
    "clicks_in_window": 311,
    "uniques_in_window": 240
  }]
}

Tags

The labels you put on your links.

GET /tags

Your tags

Every tag used on your links, with how many links carry it and how many clicks they collected, the most used first.

Parameters
  • q string query string

    Only the tags containing this text.

Request
curl https://pik.li/api/v1/tags \
  -H "Authorization: Bearer $PIKLI_KEY"
Response · 200
{
  "tags": [
    { "name": "promo", "links": 214, "clicks": 90211 },
    { "name": "newsletter", "links": 58, "clicks": 20473 }
  ],
  "total": 2
}
Errors Only the ones every endpoint can return.

Domains

Where your short links can live.

GET /domains

The domains you can use

The pik.li domains your plan allows and your verified custom domains, which one is the default, and how many custom domains your plan allows. Pass hostname as domain when creating a link.

Request
curl https://pik.li/api/v1/domains \
  -H "Authorization: Bearer $PIKLI_KEY"
Response · 200
{
  "domains": [
    { "id": 1, "hostname": "lnkz.li", "base_url": "https://lnkz.li", "kind": "system", "min_plan": "free", "status": "active", "verified": true, "default": true },
    { "id": 318, "hostname": "go.example.com", "base_url": "https://go.example.com", "kind": "custom", "min_plan": "free", "status": "active", "verified": true, "default": false }
  ],
  "default": "lnkz.li",
  "custom_domains": { "used": 1, "limit": 3 }
}
Errors Only the ones every endpoint can return.

WebhooksPlans: Premium · Business

Manage from your code the webhooks you would otherwise set up on the Webhooks page of the dashboard. How the deliveries work is described in the Webhooks section below.

GET /webhooks

List your webhooks

Your webhooks and the events you can subscribe to. The secret is never shown again after creation.

Request
curl https://pik.li/api/v1/webhooks \
  -H "Authorization: Bearer $PIKLI_KEY"
Response · 200
{
  "webhooks": [{
    "id": 17,
    "url": "https://example.com/hooks/pikli",
    "events": ["link.created", "link.disabled"],
    "active": true,
    "failures_count": 0,
    "last_delivered_at": "2026-09-22T18:04:11.520+02:00",
    "created_at": "2026-09-10T11:20:33.004+02:00",
    "updated_at": "2026-09-10T11:20:33.004+02:00",
    "signature_header": "X-Pikli-Signature"
  }],
  "events": ["link.created", "link.clicked", "link.disabled", "link.deleted"]
}
GET /webhooks/:id

Read one webhook

The webhook with its last 20 deliveries and the status your server answered to each.

Parameters
  • id integer in the path required

    The id of the webhook.

Request
curl https://pik.li/api/v1/webhooks/17 \
  -H "Authorization: Bearer $PIKLI_KEY"
Response · 200
{
  "id": 17,
  "url": "https://example.com/hooks/pikli",
  "events": ["link.created", "link.disabled"],
  "active": true,
  "failures_count": 0,
  "last_delivered_at": "2026-09-22T18:04:11.520+02:00",
  "created_at": "2026-09-10T11:20:33.004+02:00",
  "updated_at": "2026-09-10T11:20:33.004+02:00",
  "signature_header": "X-Pikli-Signature",
  "deliveries": [
    { "id": 903, "event": "ping", "response_code": 200, "attempts": 1, "delivered_at": "2026-09-22T18:04:11.520+02:00", "created_at": "2026-09-22T18:04:11.520+02:00" }
  ]
}
POST /webhooks

Create a webhook

The answer carries the secret used to sign the deliveries, only this once: store it now.

Parameters
  • url string JSON body required

    The address that receives the deliveries, http:// or https://, reachable from the internet.

  • events array | string JSON body

    The events to receive, as an array or comma-separated. Without it: all of them. See the list of events below.

  • active boolean JSON body

    false to create it paused. Default true.

Request
curl -X POST https://pik.li/api/v1/webhooks \
  -H "Authorization: Bearer $PIKLI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks/pikli","events":["link.created","link.disabled"]}'
Response · 201
{
  "id": 17,
  "url": "https://example.com/hooks/pikli",
  "events": ["link.created", "link.disabled"],
  "active": true,
  "failures_count": 0,
  "last_delivered_at": null,
  "created_at": "2026-09-10T11:20:33.004+02:00",
  "updated_at": "2026-09-10T11:20:33.004+02:00",
  "signature_header": "X-Pikli-Signature",
  "secret": "ec307f56cfac71856a10d641eee7dd58e0eb8286"
}
PATCH /webhooks/:id

Change a webhook

Send only what changes: address, events, or active to pause and resume deliveries.

PUT is accepted as well, with the same parameters.

Parameters
  • id integer in the path required

    The id of the webhook.

  • url string JSON body

    A new address.

  • events array | string JSON body

    The new list of events; it cannot be empty.

  • active boolean JSON body

    false pauses the deliveries, true resumes them.

Request
curl -X PATCH https://pik.li/api/v1/webhooks/17 \
  -H "Authorization: Bearer $PIKLI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"active":false}'
Response · 200
{
  "id": 17,
  "url": "https://example.com/hooks/pikli",
  "events": ["link.created", "link.disabled"],
  "active": false,
  "failures_count": 0,
  "last_delivered_at": "2026-09-22T18:04:11.520+02:00",
  "created_at": "2026-09-10T11:20:33.004+02:00",
  "updated_at": "2026-09-23T10:15:42.118+02:00",
  "signature_header": "X-Pikli-Signature"
}
DELETE /webhooks/:id

Delete a webhook

Deliveries stop and their history is deleted with it.

Parameters
  • id integer in the path required

    The id of the webhook.

Request
curl -X DELETE https://pik.li/api/v1/webhooks/17 \
  -H "Authorization: Bearer $PIKLI_KEY"
Response · 204
No body: the status says it all.
POST /webhooks/:id/test

Send a test delivery

Queues a ping to the webhook address, signed like every delivery: the quickest way to check your signature code. It leaves shortly after and shows up in GET /webhooks/:id.

Parameters
  • id integer in the path required

    The id of the webhook.

Request
curl -X POST https://pik.li/api/v1/webhooks/17/test \
  -H "Authorization: Bearer $PIKLI_KEY"
Response · 202
{
  "queued": true,
  "event": "ping",
  "webhook_id": 17
}

WebhooksPlans: Premium · Business

A webhook is an address on your server that pik.li calls with a POST when something happens in your account. You create one on the Webhooks page of the dashboard or through the webhook endpoints.

Events

ping
The test delivery, sent when you press Test in the dashboard or call POST /webhooks/:id/test.
link.created
A link has been created, from the dashboard or from the API. coming soon
link.clicked
Someone has clicked one of your links. coming soon
link.disabled
A link has been disabled, by you or by moderation. coming soon
link.deleted
A link has been deleted. coming soon
The link events can already be chosen in a webhook, but their deliveries have not started yet: today pik.li sends only the test ping.

What a delivery looks like

A POST with a JSON body of three fields: event, the name of the event; created_at, when it was sent; data, the details of the event. These headers come with it:

What your server receives
POST /hooks/pikli HTTP/1.1
Content-Type: application/json
X-Pikli-Event: ping
X-Pikli-Signature: sha256=bdaf35d42413498dab7fea8be1f292c36b690152e5ec7d66e6dd2c2cd0b2d61c

{"event":"ping","created_at":"2026-09-23T10:15:42+02:00","data":{"message":"pik.li webhook test","at":"2026-09-23T10:15:42+02:00","via":"api"}}
Content-Type
always application/json.
X-Pikli-Event
the name of the event, the same as in the body: route the request before reading it.
X-Pikli-Signature
sha256= followed by the signature of the body, as described below.

Verify the signature

Each delivery is signed with the secret of its webhook, which you receive once, when you create it. The X-Pikli-Signature header holds sha256= followed by the HMAC-SHA256 of the raw body, in hexadecimal. Compute the same HMAC over the bytes you received — before turning them into JSON, which would change them — and compare the two strings in constant time. If they differ, answer 401 and ignore the request.

To test your code: with the secret ec307f56cfac71856a10d641eee7dd58e0eb8286 of the example above, the body of the delivery shown here gives exactly the signature in its header.

Node.js
import crypto from "node:crypto";
import express from "express";

const app = express();

app.post("/hooks/pikli", express.raw({ type: "application/json" }), (req, res) => {
  const expected = "sha256=" + crypto
    .createHmac("sha256", process.env.PIKLI_WEBHOOK_SECRET)
    .update(req.body)
    .digest("hex");
  const received = req.get("X-Pikli-Signature") || "";
  const valid = received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  res.sendStatus(204);
});
Python
import hashlib, hmac, os
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["PIKLI_WEBHOOK_SECRET"].encode()

@app.post("/hooks/pikli")
def pikli_hook():
    body = request.get_data()
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Pikli-Signature", "")):
        abort(401)
    event = request.get_json()
    return "", 204
Ruby
class PikliHooksController < ActionController::API
  def create
    body = request.raw_post
    expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("PIKLI_WEBHOOK_SECRET"), body)
    received = request.headers["X-Pikli-Signature"].to_s
    return head(:unauthorized) unless ActiveSupport::SecurityUtils.secure_compare(expected, received)

    event = JSON.parse(body)
    head :no_content
  end
end
PHP
<?php
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, getenv('PIKLI_WEBHOOK_SECRET'));
$received = $_SERVER['HTTP_X_PIKLI_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}
$event = json_decode($body, true);
http_response_code(204);

Answers, retries and failures

  • Answer with any 2xx status as soon as you can, and do the slow work afterwards.
  • If pik.li cannot reach your address at all — network error, refused connection, an address that is not public — the delivery is tried up to 5 times, with longer and longer pauses: the last attempt comes about six minutes after the first.
  • An answer with a status of 400 or above is recorded and not repeated. It adds one to the failures_count of the webhook, which goes back to zero with the first successful delivery.
  • The last 20 deliveries, with the status your server answered, are in GET /webhooks/:id and on the Webhooks page of the dashboard.
  • The address must be public: pik.li does not call private or local networks. Use https://, so that the deliveries travel encrypted.

OpenAPI

The same reference in a machine-readable form is published at /openapi.json (OpenAPI 3.1). Import it into Postman, Insomnia or Bruno, or generate a client with openapi-generator.

Something missing, or not working the way this page says? Write to [email protected].