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 request

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

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

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_modeBehaviour
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/bulk

Up 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 parameterNotes
limit1 to 100. Default 25.
cursorThe next_cursor from the previous page. Opaque.
environmentlive or test. Defaults to the key's own environment.
statusactive, disabled, expired or flagged.
searchMatches 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"}'

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/resolve

Expands 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/quota

Reads 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/health

Unauthenticated 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
SituationResult
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

ResponseRetry?
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
LineContent
1The literal version tag PLK1.
2HTTP method, upper case.
3Path, no query string, always leading-slashed.
4Canonical query string: parameters sorted by name then value, re-encoded with RFC 3986 rules. Empty line when there is no query.
5Lowercase hex SHA-256 of the raw request body. An empty body hashes to e3b0c442…b855, not to an empty string.
6Ten-digit Unix timestamp, in seconds.
7Nonce: 16 to 64 characters of [A-Za-z0-9_-], unique for every request.
8The 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);

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.

LiveTest
Key prefixplk_live_plk_test_
Base URLIdentical — the environment is carried by the key, not by a hostname
Links resolveYesYes
Requests loggedYesYes
Consumes plan quotaYesNo
Rate limitedYesYes

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 httpsjavascript:, 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.