Reference · v1
Palms Links API
POST a long URL with a bearer key, get back a short link on pmlk.io that carries the destination's own title, description, image and canonical URL. The redirect is a 301 by default, so search engines consolidate ranking onto the destination rather than onto the short link.
On this page
Quickstart
one requesturl is the only required field. Everything else has a defensible default, so the smallest useful integration is a single call.
curl -sS https://api.palmslinks.io/v1/links \
-H "Authorization: Bearer plk_live_7Hq2NmZ4pR8sT1vW6xY0aB_kR3nQ8xW2mB5vC9dF1gH4jK7lP0sT6yU3zA5eD8n4X2q" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-48213" \
-d '{"url":"https://maps.app.goo.gl/MvmaBZ4u5zP9Xekr9"}'<?php
$curl = curl_init('https://api.palmslinks.io/v1/links');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('PALMS_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order-48213',
],
CURLOPT_POSTFIELDS => json_encode([
'url' => 'https://maps.app.goo.gl/MvmaBZ4u5zP9Xekr9',
]),
]);
$body = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$payload = json_decode((string) $body, true);
// 201 on create, 200 when an identical link already existed.
if ($status === 201 || $status === 200) {
echo $payload['data']['short_url'];
} else {
// Branch on the stable machine code, never on the human title.
throw new RuntimeException($payload['code'] ?? 'internal_error');
}const response = await fetch('https://api.palmslinks.io/v1/links', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PALMS_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'order-48213',
},
body: JSON.stringify({ url: 'https://maps.app.goo.gl/MvmaBZ4u5zP9Xekr9' }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.code);
}
console.log(payload.data.short_url);import os
import requests
response = requests.post(
"https://api.palmslinks.io/v1/links",
headers={
"Authorization": f"Bearer {os.environ['PALMS_API_KEY']}",
"Idempotency-Key": "order-48213",
},
json={"url": "https://maps.app.goo.gl/MvmaBZ4u5zP9Xekr9"},
timeout=10,
)
payload = response.json()
if response.status_code not in (200, 201):
raise RuntimeError(payload["code"])
print(payload["data"]["short_url"])201 Created
{
"data": {
"id": "5f1c1b0e-7a2f-4a1d-9c33-8a0d2b6f4e11",
"code": "A1b2C3",
"short_url": "https://pmlk.io/A1b2C3",
"url": "https://maps.app.goo.gl/MvmaBZ4u5zP9Xekr9",
"environment": "live",
"status": "active",
"title": "",
"reference": "",
"tags": [],
"redirect_type": 301,
"forward_query": true,
"expires_at": null,
"max_clicks": null,
"password_protected": false,
"clicks": 0,
"last_clicked_at": null,
"seo": {
"mode": "inherit",
"status": "ok",
"title": "Kingdom Centre, Riyadh",
"description": "Google Maps place page for Kingdom Centre.",
"image": "https://maps.gstatic.com/…/preview.png",
"site_name": "Google Maps",
"canonical": "https://maps.app.goo.gl/MvmaBZ4u5zP9Xekr9",
"locale": "en",
"type": "website",
"fetched_at": "2026-08-16T12:04:11+03:00"
},
"created_at": "2026-08-16T12:04:11+03:00",
"updated_at": "2026-08-16T12:04:11+03:00"
}
}
The seo block is the point of the platform
With seo_mode: inherit the destination is fetched once and cached by URL, so a hundred short links to the same page cost one fetch. Any crawler that asks for the short link is served the destination's own metadata with a canonical pointing at the destination — the preview card in a chat app is the destination's card, and the ranking signal follows the 301 to the destination.
Base URL
| Primary | https://api.palmslinks.io |
|---|---|
| Equivalent | https://palmslinks.io/api |
| Short domain | https://pmlk.io |
| Machine description | https://api.palmslinks.io/v1/openapi.json |
Both API hosts route identically — the /api prefix is stripped before dispatch. The short domain is a different deployment on separate hosting: it resolves codes either from its own database replica or through a signed lookup against this host, cached on the edge's own disk. It never shares a filesystem with the API, which is why an edge outage cannot take link creation down and vice versa.
HTTPS is required. A credential presented over plaintext HTTP is treated as compromised and the call is refused with 403 https_required rather than silently upgraded.
Authentication
Send the key whole, as a bearer token:
Authorization: Bearer plk_live_7Hq2NmZ4pR8sT1vW6xY0aB_kR3nQ8xW2mB5vC9dF1gH4jK7lP0sT6yU3zA5eD8n4X2q
If your platform strips Authorization — some managed integration tools do — X-API-Key and X-Palms-Key carry the same value and are handled identically.
Key anatomy
plk_live_7Hq2NmZ4pR8sT1vW6xY0aB_kR3nQ8xW2mB5vC9dF1gH4jK7lP0sT6yU3zA5eD8n4X2q
|-- environment --||------ key id -----| |------------- secret -------------||checksum|
| Prefix | plk_live_ or plk_test_. A recognisable prefix is what lets a secret scanner catch a leaked key in a commit, and what makes an environment mix-up obvious to a human reading a log. |
|---|---|
| Key id | Public, indexed, 22 base62 characters after the prefix. It appears in logs and in /v1/me. Quote it in support requests — it identifies the key without revealing it. |
| Secret | 40 base62 characters, shown once at issue and never recoverable. Stored as an HMAC digest under a server-side pepper that lives outside the database, so a database dump cannot verify — let alone forge — a key. If it is lost, rotate the key. |
| Checksum | Six characters. A mistyped key fails the checksum and is rejected before any database lookup happens, so typos never reach the rate limiter or the log. |
Environments
plk_live_ keys write to the live environment. plk_test_ keys behave identically — the links resolve, the requests are logged, the errors are the same — but they never consume production quota. Use a test key in CI.
Your first call
GET /v1/me tells you what your key is, what it may do and where it sits in the tenancy. When a request is rejected and you cannot see why, this is the endpoint that answers it.
curl -sS https://api.palmslinks.io/v1/me -H "Authorization: Bearer $PALMS_API_KEY"
Scopes
Deliberately few. A scope that is never checked is worse than no scope at all, because it implies a control that does not exist. This table is generated from the same list the authenticator enforces.
| Scope | Allows | Endpoints |
|---|---|---|
| links:write | Create and update short links | POST /v1/links, POST /v1/links/bulk, PATCH and PUT /v1/links/{id} |
| links:read | Read link details and list links | GET /v1/links, GET /v1/links/{id}, GET /v1/resolve |
| links:delete | Disable and delete links | DELETE /v1/links/{id} |
| stats:read | Read click statistics | GET /v1/links/{id}/stats |
A key without the scope receives 403 insufficient_scope, with the required and the granted scopes in the body so the fix is obvious. GET /v1/scopes returns this list without a credential.
Endpoint index
| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | / | — | Service index |
| GET | /v1/health | — | Liveness. No credential needed |
| GET | /v1/errors | — | The complete error catalogue |
| GET | /v1/scopes | — | The scope list |
| GET | /v1/openapi.json | — | This API, as OpenAPI 3.1 |
| GET | /v1/me | any | Key, client, domain and subscription context |
| GET | /v1/quota | any | Usage against plan limits. Does not consume rate limit |
| POST | /v1/links | links:write | Create a short link |
| POST | /v1/links/bulk | links:write | Create many, with per-item results |
| GET | /v1/links | links:read | List, cursor paginated |
| GET | /v1/links/{id} | links:read | Read one |
| PATCH | /v1/links/{id} | links:write | Partial update |
| PUT | /v1/links/{id} | links:write | Alias of PATCH, for clients that cannot send it |
| DELETE | /v1/links/{id} | links:delete | Retire a link |
| GET | /v1/links/{id}/stats | stats:read | Aggregated clicks |
| GET | /v1/resolve | links:read | Expand a code without following it |
{id} accepts a numeric link id, its UUID, or its short code — whichever you happen to be holding.
Create a link
POST /v1/links| Field | Type | Notes |
|---|---|---|
| url | string | Required. http or https only, at most 2048 characters. A missing scheme is assumed to be https. |
| alias | string | A custom code: 3–32 characters of [A-Za-z0-9_-], starting and ending alphanumeric. Requires the plan's custom_alias feature. "code", "custom_alias" and "slug" are accepted as synonyms. |
| title | string | Internal label for the control panel. Never shown to a visitor. |
| reference | string | Your correlation value — an order number, a CRM id. Echoed back unchanged and searchable. |
| tags | string[] | Up to 20 tags, 40 characters each. |
| notes | string | Up to 5000 characters. Panel only. |
| redirect_type | int | 301, 302, 307 or 308. Default 301. |
| forward_query | bool | Append the short link's own query string to the destination. Default true. |
| utm | object | utm_source, utm_medium, utm_campaign, utm_term, utm_content, utm_id. Merged into the destination without overwriting parameters it already carries. |
| expires_at | string | int | null | ISO 8601 or Unix seconds. Must be in the future. |
| expires_url | string | Where an expired link sends visitors. |
| max_clicks | int | null | The link expires once this many clicks have been served. |
| password | string | Visitors must enter it before the redirect. Argon2id hashed; never returned. |
| seo_mode | string | inherit (default), custom, or off. |
| seo_title, seo_description, seo_image | string | Supplying any of these implies seo_mode: custom. |
| seo_canonical | string | Defaults to the destination URL. |
| fetch_metadata | bool | Fetch the destination metadata during this call. Default true; false defers it to the queue. |
| deduplicate | bool | Return an existing identical link rather than minting a second. Default true. |
A fully specified request
curl -sS https://api.palmslinks.io/v1/links \
-H "Authorization: Bearer $PALMS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: campaign-2026-08-riyadh" \
-d '{
"url": "https://example.com/autumn-offer",
"alias": "autumn26",
"title": "Autumn offer - print run",
"reference": "CAMP-2026-08",
"tags": ["print", "riyadh"],
"redirect_type": 301,
"forward_query": true,
"utm": {"utm_source": "flyer", "utm_medium": "print"},
"expires_at": "2026-11-30T20:59:59Z",
"seo_mode": "inherit"
}'$payload = [
'url' => 'https://example.com/autumn-offer',
'alias' => 'autumn26',
'reference' => 'CAMP-2026-08',
'tags' => ['print', 'riyadh'],
'redirect_type' => 301,
'utm' => ['utm_source' => 'flyer', 'utm_medium' => 'print'],
'expires_at' => '2026-11-30T20:59:59Z',
];
$curl = curl_init('https://api.palmslinks.io/v1/links');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('PALMS_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: campaign-2026-08-riyadh',
],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$body = curl_exec($curl);const payload = {
url: 'https://example.com/autumn-offer',
alias: 'autumn26',
reference: 'CAMP-2026-08',
tags: ['print', 'riyadh'],
redirect_type: 301,
utm: { utm_source: 'flyer', utm_medium: 'print' },
expires_at: '2026-11-30T20:59:59Z',
};
const response = await fetch('https://api.palmslinks.io/v1/links', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PALMS_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'campaign-2026-08-riyadh',
},
body: JSON.stringify(payload),
});payload = {
"url": "https://example.com/autumn-offer",
"alias": "autumn26",
"reference": "CAMP-2026-08",
"tags": ["print", "riyadh"],
"redirect_type": 301,
"utm": {"utm_source": "flyer", "utm_medium": "print"},
"expires_at": "2026-11-30T20:59:59Z",
}
response = requests.post(
"https://api.palmslinks.io/v1/links",
headers={
"Authorization": f"Bearer {os.environ['PALMS_API_KEY']}",
"Idempotency-Key": "campaign-2026-08-riyadh",
},
json=payload,
timeout=10,
)Choosing a redirect type
301 is the default and is almost always right: it is the canonicalisation signal search engines act on, so ranking consolidates onto the destination — the behaviour this platform exists to provide. The edge caps 301 caching at five minutes, so changing the destination later still propagates.
Choose 302 only when per-click analytics accuracy matters more than SEO: browsers cache a 301 aggressively and repeat visits may never reach the edge to be counted. 307 and 308 preserve the request method and body — use them only when the short link is consumed by a machine that issues a POST.
De-duplication
By default, shortening a URL your subscription has already shortened returns the existing link with 200 and "deduplicated": true instead of a second code for the same destination.
A link only counts as a duplicate when the behaviour matches too: same environment, same redirect type, same query forwarding, and no password, expiry or click limit on either side. Otherwise you would silently receive a link that behaves differently from the one you asked for. Send "deduplicate": false for a distinct code — two campaigns pointing at one landing page, for instance.
SEO inheritance
| seo_mode | Behaviour |
|---|---|
| inherit | Default. The destination is fetched once and the short link presents the destination's own title, description, image and canonical URL to any crawler that asks. Humans still get the redirect. |
| custom | You supply the metadata. Implied as soon as any seo_ field is set. |
| off | A bare redirect with no preview card at all. |
The fetch is cached by destination URL, so a hundred short links to one page cost one fetch. seo.status reports where it got to: pending, ok, failed or skipped. pending is not an error — it means the fetch was deferred to the queue and completes within the hour. The link resolves either way.
Bulk create
POST /v1/links/bulkUp to 100 items per request; the ceiling is a platform setting and batch_too_large names the real value in its message. Each item is a URL string or a full create-link object. Requires the plan's bulk_create feature.
curl -sS https://api.palmslinks.io/v1/links/bulk \
-H "Authorization: Bearer $PALMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"links": ["https://example.com/a", {"url": "https://example.com/b", "reference": "B-2"}]}'
207 Multi-Status
{
"data": [
{ "index": 0, "status": 201, "data": { "code": "Kq7mZ2", "short_url": "https://pmlk.io/Kq7mZ2" } },
{ "index": 1, "status": 422, "error": { "code": "url_invalid", "detail": "The URL is missing a hostname." } }
],
"summary": { "total": 2, "succeeded": 1, "failed": 1 }
}
The response is 207, not 200 and not 400: one bad URL in fifty must not discard the other forty-nine, so every item carries its own status and the summary gives you the counts. Treat 207 as a success at the transport level and inspect each item.
List links
GET /v1/links| Query parameter | Notes |
|---|---|
| limit | 1 to 100. Default 25. |
| cursor | The next_cursor from the previous page. Opaque. |
| environment | live or test. Defaults to the key's own environment. |
| status | active, disabled, expired or flagged. |
| search | Matches an exact code, an exact reference, or a substring of the destination host or the title. |
curl -sS "https://api.palmslinks.io/v1/links?limit=50&status=active" \
-H "Authorization: Bearer $PALMS_API_KEY"
{
"data": [ ... ],
"pagination": { "limit": 50, "has_more": true, "next_cursor": "aWQ6MTQwOTc" }
}
Cursor pagination
Pass next_cursor back as ?cursor=, and stop when has_more is false. Treat the cursor as opaque: it encodes the last id seen and its format is not part of the contract.
Cursor rather than offset because with offset pagination, links created while you are walking the list shift every subsequent page and rows are silently skipped.
let cursor = null;
const all = [];
do {
const url = new URL('https://api.palmslinks.io/v1/links');
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const page = await (await fetch(url, { headers })).json();
all.push(...page.data);
cursor = page.pagination.has_more ? page.pagination.next_cursor : null;
} while (cursor);
Retrieve a link
GET /v1/links/{id}{id} is a numeric id, a UUID or a short code. All three resolve to the same record, so you never have to store a second identifier just to be able to read a link back.
# By short code
curl -sS https://api.palmslinks.io/v1/links/A1b2C3 -H "Authorization: Bearer $PALMS_API_KEY"
# By UUID
curl -sS https://api.palmslinks.io/v1/links/5f1c1b0e-7a2f-4a1d-9c33-8a0d2b6f4e11 \
-H "Authorization: Bearer $PALMS_API_KEY"
A link belonging to another subscription returns 404 link_not_found, not 403 — a tenancy boundary that answers "exists but not yours" is an enumeration oracle.
Update a link
PATCH /v1/links/{id}Partial: send only what changes. PUT is accepted as an alias with identical semantics, for clients that cannot issue a PATCH. The short code itself is immutable — a code that has been published cannot be re-pointed to a different owner's destination by editing one field.
curl -sS -X PATCH https://api.palmslinks.io/v1/links/A1b2C3 \
-H "Authorization: Bearer $PALMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/autumn-offer-v2", "status": "active"}'await fetch(`https://api.palmslinks.io/v1/links/${code}`, {
method: 'PATCH',
headers,
body: JSON.stringify({ url: 'https://example.com/autumn-offer-v2' }),
});requests.patch(
f"https://api.palmslinks.io/v1/links/{code}",
headers=headers,
json={"url": "https://example.com/autumn-offer-v2"},
timeout=10,
)Changing url re-runs destination validation and, in inherit mode, re-fetches the metadata. Because the edge caps 301 caching at five minutes, a repointed link propagates to visitors quickly rather than being frozen in browser caches.
Delete a link
DELETE /v1/links/{id}curl -sS -X DELETE https://api.palmslinks.io/v1/links/A1b2C3 \
-H "Authorization: Bearer $PALMS_API_KEY" -i
HTTP/1.1 204 No Content
Deletion is a retirement, not an erasure
The row is soft-deleted and the code is retained forever so it can never be reissued. A six-character base62 code has 626 = 56,800,235,584 possibilities drawn from a CSPRNG, so there is no pressure to recycle — and recycling is exactly how a printed link ends up pointing at somebody else's destination years later. Statistics and audit entries for the link survive the delete.
Statistics
GET /v1/links/{id}/stats?days= accepts 1 to 365 and defaults to 30; your plan's analytics window bounds how far back data exists.
curl -sS "https://api.palmslinks.io/v1/links/A1b2C3/stats?days=30" \
-H "Authorization: Bearer $PALMS_API_KEY"
{
"data": {
"link": { "id": "5f1c1b0e-7a2f-4a1d-9c33-8a0d2b6f4e11", "code": "A1b2C3" },
"window_days": 30,
"total_clicks": 18422,
"window_totals": { "clicks": 2043, "unique": 1786, "bots": 118, "previews": 96 },
"series": [
{ "date": "2026-08-15", "clicks": 74, "unique": 66, "bots": 3, "previews": 2 },
{ "date": "2026-08-16", "clicks": 91, "unique": 80, "bots": 4, "previews": 1 }
],
"countries": [ { "name": "SA", "count": 1502 }, { "name": "AE", "count": 311 } ],
"referrers": [ { "name": "x.com", "count": 402 } ],
"devices": [ { "name": "mobile", "count": 1610 } ]
}
}
Everything comes from a pre-aggregated daily rollup, never from raw click rows. The raw rows are pseudonymised before they are written and purged on the retention schedule, so there is no per-visitor data to return and none is returned. Breakdowns are counts, capped at the 25 largest entries.
total_clicks is lifetime; window_totals covers the requested window. unique counts distinct visitors per day and is filled in by the hourly job once a day closes, so today's value is typically 0 until then. That is expected, not a bug.
Resolve a code
GET /v1/resolveExpands a short code back to its destination without following the redirect and without recording a click. Send either code or a whole url — whichever you happen to be holding.
curl -sS "https://api.palmslinks.io/v1/resolve?code=A1b2C3" \
-H "Authorization: Bearer $PALMS_API_KEY"
curl -sS --get https://api.palmslinks.io/v1/resolve \
--data-urlencode "url=https://pmlk.io/A1b2C3" \
-H "Authorization: Bearer $PALMS_API_KEY"
The response body is the same link object POST /v1/links returns. Use this to display or verify a target inside your own product rather than issuing an HTTP request to the short domain and reading the Location header — that would count as a click.
Key context
GET /v1/me{
"data": {
"key": {
"id": "1b9a7f10-2c44-4f2e-9a7d-3f5c81b2e0d4",
"key_id": "plk_live_7Hq2NmZ4pR8sT1vW6xY0aB",
"name": "Production — order service",
"environment": "live",
"scopes": ["links:write", "links:read", "stats:read"],
"signed_requests_required": false,
"target_host_policy": "any",
"created_at": "2026-02-01T09:14:00+03:00",
"last_used_at": "2026-08-16T11:58:20+03:00"
},
"client": { "name": "Example Trading Co." },
"domain": { "host": "example.com", "status": "verified" },
"subscription": {
"reference": "SUB-2026-000042",
"status": "active",
"plan": "Business",
"plan_code": "business",
"starts_at": "2026-02-01T00:00:00+03:00",
"ends_at": "2027-01-31T23:59:59+03:00",
"days_remaining": 168
},
"endpoints": {
"create_link": "https://api.palmslinks.io/v1/links",
"short_domain": "https://pmlk.io",
"documentation": "https://palmslinks.io/docs"
}
}
}
Four of these fields answer almost every "why was my request rejected" question on their own: environment, scopes, domain.status and subscription.status.
Quota
GET /v1/quotaReads the rate-limit state without consuming it, so polling this endpoint never costs you allowance. A null limit means unlimited.
{
"data": {
"environment": "live",
"rate_limits": {
"per_minute": { "limit": 600, "used": 14, "remaining": 586, "resets_at": "2026-08-16T12:05:00+00:00" },
"per_day": { "limit": 250000, "used": 9122, "remaining": 240878, "resets_at": "2026-08-17T00:00:00+00:00" }
},
"usage": { "links_created": { "limit": 25000, "used": 4180, "period": "2026-08" } },
"features": { "custom_alias": true, "bulk_create": true, "webhooks": true, "signed_requests": true },
"note": null
}
}
Health
GET /v1/healthUnauthenticated by design — a monitoring probe should not need a credential — and it deliberately reveals nothing beyond liveness. Returns 200 when healthy and 503 when the database is unreachable or the platform is in maintenance mode.
{ "status": "ok", "service": "palms-links-api", "version": "v1", "time": "2026-08-16T09:04:11+00:00" }
Point your uptime monitor here rather than at /v1/links: a monitor that writes is a monitor that consumes quota and creates rows.
Catalogues
Three unauthenticated endpoints exist so an integrator can build exhaustive handling without scraping this page. All three are cacheable for an hour.
| GET /v1/errors | Every code in the catalogue with its HTTP status and title — the same rows as the table below. |
|---|---|
| GET /v1/scopes | Every scope with its description. |
| GET /v1/openapi.json | OpenAPI 3.1. Generate a client from it rather than hand-writing one. |
curl -sS https://api.palmslinks.io/v1/errors | jq '.data[] | select(.status == 429)'
Idempotency
Send an Idempotency-Key header on POST /v1/links. A caller that times out and retries must not end up with two links.
Idempotency-Key: order-48213-attempt-1
| Situation | Result |
|---|---|
| Same key, same body | The original response is replayed, with Idempotency-Replayed: true. |
| Same key, different body | 409 idempotency_conflict. That is a client bug, not a retry — use a new key for a new operation. |
| Same key while the first is still running | 409 idempotency_in_progress with Retry-After: 2. |
Keys live 24 hours. Only successful outcomes are stored: a failure stays retryable, because freezing a 500 for a day would strand you. The first request claims the key with an insert — the unique index is the lock — so two concurrent duplicates cannot both proceed.
Use it on anything triggered by a webhook, a queue, or a user action that can be double-clicked. Always send one on a retried POST.
Rate limits and quota
Two sliding windows are enforced together, per key, both taken from the plan: per minute and per day. The platform default is 120 requests per minute where a plan does not override it. Every response carries the state.
RateLimit-Limit: 600
RateLimit-Remaining: 586
RateLimit-Reset: 24
RateLimit-Policy: 600;w=60
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 586
X-RateLimit-Reset: 1786954486
Retry-After: 24 (only when the window is exhausted)
RateLimit-Reset is a delta in seconds. X-RateLimit-Reset is an absolute Unix timestamp — the two spellings disagree by convention, not by accident, so read the one you expect.
429 rate_limited is transient
Back off for Retry-After seconds and retry. The window is sliding, not fixed, so the allowance returns gradually rather than all at once on a boundary.
429 quota_exceeded is not
It will not clear until the period rolls over or the plan changes. Retrying is pointless; the body names the metric, the limit, the amount used and the period.
{
"type": "https://palmslinks.io/errors/quota_exceeded",
"title": "Quota exceeded",
"status": 429,
"code": "quota_exceeded",
"detail": "The plan allows 25,000 new links for 2026-08 and that allowance is used up.",
"quota": { "metric": "links_created", "limit": 25000, "used": 25000, "period": "2026-08" },
"request_id": "req_9f2ea1c0b4d3"
}
A retry policy that works
| Response | Retry? |
|---|---|
| 408, 429, 500, 502, 503, 504 | Yes — exponential backoff, honour Retry-After, always with an Idempotency-Key. |
| 409 idempotency_in_progress | Yes, after Retry-After. |
| 429 quota_exceeded | No. Wait for the period or change the plan. |
| Everything else in 4xx | No. Fix the request. |
Signed requests
A bearer token proves who you are. A signature proves the request was not altered and is not a replay. Keys handling regulated data can be marked require_signature, after which an unsigned call is refused with 401 signature_missing.
The canonical string
Exactly these eight newline-separated lines. The block below is produced by the same function the server uses to verify, so it cannot drift from the implementation.
PLK1
POST
/v1/links
651ad8a996c6c47114cd18d749515ca685a367b7b2eccc6b2788f10683034b1e
1786954462
9f2ea1c0b4d3a71e5c08
plk_live_7Hq2NmZ4pR8sT1vW6xY0aB
| Line | Content |
|---|---|
| 1 | The literal version tag PLK1. |
| 2 | HTTP method, upper case. |
| 3 | Path, no query string, always leading-slashed. |
| 4 | Canonical query string: parameters sorted by name then value, re-encoded with RFC 3986 rules. Empty line when there is no query. |
| 5 | Lowercase hex SHA-256 of the raw request body. An empty body hashes to e3b0c442…b855, not to an empty string. |
| 6 | Ten-digit Unix timestamp, in seconds. |
| 7 | Nonce: 16 to 64 characters of [A-Za-z0-9_-], unique for every request. |
| 8 | The key id — the public half of the credential, not the secret. |
Headers
X-Palms-Key: plk_live_7Hq2NmZ4pR8sT1vW6xY0aB
X-Palms-Timestamp: 1786954462
X-Palms-Nonce: 9f2ea1c0b4d3a71e5c08
X-Palms-Signature: v1=<hex HMAC-SHA256 of the canonical string under the signing secret>
The signing secret is a separate credential from the bearer token, issued alongside the key. A bare hex signature without the v1= prefix is also accepted, because integrators send both in practice.
$method = 'POST';
$path = '/v1/links';
$query = ''; // already canonicalised, or empty
$body = json_encode(['url' => 'https://example.com/page']);
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(12));
$canonical = implode("\n", [
'PLK1',
strtoupper($method),
$path,
$query,
hash('sha256', $body),
$timestamp,
$nonce,
$keyId,
]);
$signature = 'v1=' . hash_hmac('sha256', $canonical, $signingSecret);import { createHash, createHmac, randomBytes } from 'node:crypto';
const body = JSON.stringify({ url: 'https://example.com/page' });
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = randomBytes(12).toString('hex');
const canonical = [
'PLK1',
'POST',
'/v1/links',
'',
createHash('sha256').update(body).digest('hex'),
timestamp,
nonce,
keyId,
].join('\n');
const signature = `v1=${createHmac('sha256', signingSecret).update(canonical).digest('hex')}`;import hashlib, hmac, os, secrets, time
body = json.dumps({"url": "https://example.com/page"}).encode()
timestamp = str(int(time.time()))
nonce = secrets.token_hex(12)
canonical = "\n".join([
"PLK1",
"POST",
"/v1/links",
"",
hashlib.sha256(body).hexdigest(),
timestamp,
nonce,
key_id,
])
signature = "v1=" + hmac.new(
signing_secret.encode(), canonical.encode(), hashlib.sha256
).hexdigest()Why the query is sorted
Sorting by name then value, then re-encoding with RFC 3986 rules, is what makes a signature survive a proxy or a client library reordering the query string. Without it, a correct request fails because something in the middle rewrote ?b=2&a=1 as ?a=1&b=2.
Replay protection
The nonce is claimed by a unique index, not by an application check, so two concurrent replays cannot both win the race. The acceptance window is 300 seconds of clock skew, and signature_expired almost always means the client's clock is wrong rather than the signature.
Diff against a known-good string
The control panel's API playground renders the canonical string for a real request. When a signature will not verify, compare yours to that one character by character — the mismatch is almost always a trailing newline, a re-serialised body, or line 4 filled in when it should be empty.
Test environment
Every subscription is issued plk_test_ keys alongside its live keys. A test key is not a stub: the request is authenticated, validated, rate limited, logged and audited exactly as a live one, the link is created, and the short URL resolves. The only difference is that the work is recorded against the test environment and never consumes production quota.
| Live | Test | |
|---|---|---|
| Key prefix | plk_live_ | plk_test_ |
| Base URL | Identical — the environment is carried by the key, not by a hostname | |
| Links resolve | Yes | Yes |
| Requests logged | Yes | Yes |
| Consumes plan quota | Yes | No |
| Rate limited | Yes | Yes |
Because the environment travels with the credential, promoting an integration from CI to production is a change of one environment variable. Nothing in your code branches on a hostname, and there is no sandbox URL to forget to change. GET /v1/links defaults to listing the key's own environment, so a test key never shows you production data by accident.
Destination rules
A URL is refused before storage if it:
- uses any scheme other than http or https — javascript:, data: and file: are rejected explicitly, not merely unhandled;
- contains credentials, as in https://user:pass@host/;
- contains control characters, or exceeds 2048 characters;
- resolves to a literal private, loopback, link-local or otherwise reserved IP address;
- is on the platform blocklist, or on the platform's own domains — shortening the short domain would create a redirect loop;
- falls outside the key's target-host policy, when it has one.
Separately, the platform never fetches a URL whose DNS answers are not all public, and it pins the connection to the address it validated. Checking without pinning is a time-of-check/time-of-use bug: the name resolves safely during the check and to a metadata endpoint microseconds later. That is why a metadata fetch for an internal host fails rather than returning content — and it is deliberate.
Shortening another shortener's link is legitimate and supported. Shortening ours is not.
Error catalogue
Every failure is RFC 9457 Problem Details, served as application/problem+json.
{
"type": "https://palmslinks.io/errors/alias_taken",
"title": "Alias already in use",
"status": 409,
"code": "alias_taken",
"detail": "That alias was taken while the request was in flight.",
"request_id": "req_9f2ea1c0b4d3"
}
Branch on code, never on title
code is stable and is part of the contract. title and detail are written for humans and may be reworded without that being a breaking change. request_id identifies the call in the platform's request log — quote it in a support message and the answer takes minutes instead of hours.
Every code the API can return
Generated from the platform's error catalogue, so this table cannot fall behind the implementation. GET /v1/errors returns the same rows as JSON.
| Status | Code | Title | What to do |
|---|---|---|---|
| 400 | invalid_request | Invalid request | A field is present but unusable. The detail names it. |
| 400 | malformed_json | Malformed JSON | The body is not valid JSON. Check the serialiser and the Content-Type. |
| 400 | missing_parameter | Missing parameter | A required field or query parameter is absent. |
| 401 | authentication_required | Authentication required | Send the Authorization header. |
| 401 | invalid_credentials | Invalid credentials | The key is wrong, truncated or unknown. Compare against /v1/me. |
| 401 | key_expired | API key expired | Issue a new key in the control panel. |
| 401 | key_revoked | API key revoked | Issue a new key in the control panel. |
| 401 | signature_expired | Request signature expired | The client clock is outside the accepted window. Fix the clock. |
| 401 | signature_invalid | Invalid request signature | Rebuild the canonical string; diff it against the playground. |
| 401 | signature_missing | Request signature required | This key requires signing. Send the three X-Palms-* headers. |
| 401 | signature_replayed | Request signature already used | Generate a fresh nonce for every request. |
| 402 | subscription_expired | Subscription expired | Renew. Existing links keep resolving; new ones do not. |
| 402 | subscription_missing | No active subscription | The key is not bound to a live subscription. |
| 402 | subscription_suspended | Subscription suspended | Contact us — billing or policy hold. |
| 403 | client_suspended | Client suspended | Contact us — the account is on hold. |
| 403 | destination_not_permitted | Destination not permitted | This key may only shorten URLs on its own domain or allowlist. |
| 403 | domain_not_verified | Domain not verified | Complete ownership verification for the domain. |
| 403 | domain_suspended | Domain suspended | Contact us — the domain is on hold. |
| 403 | feature_unavailable | Feature not included in plan | The plan does not include it — custom aliases, bulk, webhooks. |
| 403 | https_required | HTTPS required | Call over HTTPS. A credential sent in plaintext is treated as compromised. |
| 403 | insufficient_scope | Insufficient scope | The key lacks the scope named in the body. |
| 403 | ip_not_allowed | Source address not allowed | Your source address is outside the key allowlist. |
| 403 | origin_not_allowed | Origin not allowed | The browser Origin is not on the key allowlist. |
| 404 | link_not_found | Link not found | No link matches that identifier for this subscription. |
| 404 | not_found | Not found | No route matches. Check the method and the path. |
| 405 | method_not_allowed | Method not allowed | The path exists but not for this method. |
| 409 | alias_taken | Alias already in use | Choose another alias. |
| 409 | idempotency_conflict | Idempotency key reused with a different body | The key was used with a different body. Use a new key. |
| 409 | idempotency_in_progress | An identical request is still being processed | Retry after Retry-After seconds. |
| 415 | unsupported_media_type | Unsupported media type | Send application/json. |
| 422 | alias_invalid | Invalid alias | 3 to 32 characters of [A-Za-z0-9_-], alphanumeric at both ends. |
| 422 | alias_reserved | Alias reserved | That alias is on the reserved list. |
| 422 | batch_too_large | Batch too large | Split the batch; the detail names the ceiling. |
| 422 | destination_blocked | Destination blocked by policy | Platform policy refuses this destination. |
| 422 | expiry_invalid | Invalid expiry | expires_at must parse and must be in the future. |
| 422 | host_blocked | Destination host blocked | Platform policy refuses this host. |
| 422 | host_not_allowed | Destination host not allowed | Outside the key target-host policy. |
| 422 | redirect_type_invalid | Invalid redirect type | 301, 302, 307 or 308. |
| 422 | scheme_not_allowed | URL scheme not allowed | http or https only. |
| 422 | status_invalid | Invalid status | active, disabled, expired or flagged. |
| 422 | url_has_credentials | Destination URL contains credentials | Strip user:pass from the destination. |
| 422 | url_invalid | Invalid destination URL | Fix the destination — it is not a usable absolute URL. |
| 422 | url_required | Destination URL required | Send a "url" field. |
| 422 | url_too_long | Destination URL too long | At most 2048 characters. |
| 422 | validation_failed | Validation failed | Per-field messages are in the "errors" object. |
| 429 | quota_exceeded | Quota exceeded | Not transient. Wait for the period or change the plan. |
| 429 | rate_limited | Rate limit exceeded | Transient. Back off for Retry-After seconds. |
| 500 | internal_error | Internal server error | Retry with backoff. Quote the request_id if it persists. |
| 503 | code_allocation_failed | Could not allocate a short code | Transient. Retry once. |
| 503 | dependency_unavailable | A dependency is unavailable | Transient. Retry with backoff. |
| 503 | maintenance | Service temporarily unavailable | The platform is offline. Retry after Retry-After. |
51 codes in total. A code that is not in this table is not something the API emits — if you receive one, it came from something in front of the API, and the response will not be application/problem+json.
Support
The fastest possible support message
- The request_id from the failing response.
- The key id — plk_live_7Hq2NmZ4pR8sT1vW6xY0aB shape. Never the secret.
- Roughly when it happened, and what you expected instead.
Those three find the exact call in the request log. Everything else is optional.