API reference

REST API
Build on your links.

Authenticate with an API token, list and create links, read visits, and subscribe to webhooks.

Base URL https://letl.ink/api/v1 HTTPS only

Plans Growth Ultimate
On this page

Getting started

The REST API is available on Growth and Ultimate. Requests go to https://letl.ink/api/v1 over HTTPS. A workspace on Free or Solo receives 403 plan_required from /api/v1; the MCP endpoint is gated separately on Ultimate.

  1. Open Integrations → REST API (workspace owners and admins only).
  2. Generate a token. It does not expire.
  3. Store it like a password. One token serves the REST API, the MCP server and every automation platform connected to this workspace, so regenerating it invalidates all of them at once.

Members can copy an existing token if one has been generated; only owners and admins can rotate it.

Authentication

Send the token as X-API-KEY or as Authorization: Bearer. Both are accepted on /api/v1 and on /mcp. A request with no token at all returns 401 unauthenticated.

GET Who am I?
curl -H "X-API-KEY: YOUR_TOKEN" \
  https://letl.ink/api/v1/auth/me

Equivalent header: Authorization: Bearer YOUR_TOKEN

Conventions

These hold across every endpoint below.

Response envelope

Every successful response is a JSON object with a data key, and a meta key when there is anything to say about the result — pagination, the resolved date window, whether geography was included. Every failure is an object with an error key holding a stable code, a human message, and sometimes details. Branch on code, never on message text.

Shapes
// success
{ "data": { … }, "meta": { … } }

// failure
{ "error": { "code": "invalid_parameter",
             "message": "sort must be one of: created_at, clicks.",
             "details": { "parameter": "sort" } } }

Pagination

List endpoints accept limit (1–200, default 50) and offset. The meta block reports total, has_more and next_offset, so you can page without doing arithmetic: keep calling with offset = meta.next_offset until has_more is false.

Paging through every link
offset=0
while [ "$offset" != "null" ]; do
  page=$(curl -s -H "X-API-KEY: $TOKEN" \
    "https://letl.ink/api/v1/links?limit=200&offset=$offset")
  echo "$page" | jq -c '.data[]'
  offset=$(echo "$page" | jq -r '.meta.next_offset')
done

Sorting, filtering and search

GET /links accepts sort (created_at, clicks, short_code, title, updated_at), direction (asc/desc), search (matches short code, title and destination), type, archived, favorite and domain. Unknown values are rejected with invalid_parameter and the list of what is allowed, rather than being silently ignored.

Date windows

start_date and end_date use YYYY-MM-DD and are inclusive of both days. A window is clamped to your plan's retention floor — asking a 30-day-retention plan for a year of history returns the 30 days it has, and meta.date_range tells you the window actually used.

Rate limits

600 requests per minute per account, on a sliding window. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (a Unix timestamp). Exceeding the limit returns 429 rate_limited with a Retry-After header in seconds. Back off on Retry-After rather than retrying immediately.

Headers on every response
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 586
X-RateLimit-Reset: 1789000816

Versioning

The path carries the version. Adding a field to a response or a new optional parameter is not a breaking change and can happen at any time, so parse defensively and ignore keys you do not recognise. Anything that would change or remove an existing field ships under a new version prefix.

Endpoints

Account

GET /me Connection test

Identity, workspace, plan and every entitlement that plan carries. The cheapest way to verify a token and to discover what the account is allowed to do before you attempt it.

GET cURL
curl -H "X-API-KEY: YOUR_TOKEN" \
  https://letl.ink/api/v1/me
Response 200
{
  "data": {
    "user": { "id": 1, "email": "[email protected]", "name": "You" },
    "workspace": { "id": 12, "name": "My Workspace", "block_bots": true },
    "plan": {
      "id": "growth",
      "name": "Growth",
      "limits": {
        "links": 200,
        "monthly_clicks": 100000,
        "custom_domains": 3,
        "qr_codes": 30,
        "retention_days": 365
      },
      "features": {
        "api": true,
        "webhooks": true,
        "integrations": true,
        "ai_access": false,
        "geo_analytics": false,
        "smart_link_types": true
      }
    },
    "base_domain": "letl.ink"
  }
}
GET /usage

How much of each allowance is consumed this billing month. Use it to warn a customer before they hit the tracked-click ceiling — past it the redirect still works, but clicks stop being recorded.

Response 200
{
  "data": {
    "links": { "used": 42, "limit": 200, "remaining": 158, "percent_used": 21.0 },
    "clicks_this_month": { "used": 8140, "limit": 100000, "remaining": 91860, "percent_used": 8.1 },
    "custom_domains": { "used": 1, "limit": 3, "remaining": 2, "percent_used": 33.3 },
    "qr_codes": { "used": 4, "limit": 30, "remaining": 26, "percent_used": 13.3 }
  },
  "meta": { "plan": "growth", "period": "2026-09" }
}
GET /domains

Custom domains on the workspace and whether each one has finished DNS verification. Only verified domains may be used as the domain field when creating a link.

GET /integrations

Every integration the product offers, whether your plan includes it, whether this workspace has connected it, and how delivery is going — last status, last error and consecutive failure count. Useful for monitoring a fleet of workspaces.

Links

Analytics

GET /analytics/summary

The headline numbers for the whole workspace: clicks all-time and in the window, unique visitors, QR scans, peak day, and hour-of-day and day-of-week distributions. Accepts days (default 30, clamped to your retention).

Response 200 (abridged)
{
  "data": {
    "window_days": 30,
    "clicks": {
      "total": 24592, "window": 8140, "last_7_days": 2103,
      "daily_average": 271,
      "peak_day": { "date": "Sep 02", "clicks": 612 }
    },
    "visitors": { "unique_total": 18204, "unique_window": 6021 },
    "qr": { "scans_total": 940, "scans_window": 310 },
    "timing": {
      "by_hour": [12, 4, 2, …],
      "by_weekday": [1420, 1610, 1585, 1490, 1310, 480, 245],
      "peak_hour": { "peakHour": 14, "peakValue": 812, "label": "14:00–14:59" },
      "peak_weekday": { "peakDay": 1, "peakValue": 1610, "label": "Tuesday" }
    },
    "links": { "active": 42, "limit": 200 }
  },
  "meta": { "plan": "growth", "geo_included": true, "retention_days": 365 }
}
GET /analytics/timeseries

Daily click counts, ready to chart. Pass link_id to scope it to one link, and days to set the window. Days with no traffic are returned as zero rather than omitted, so the series is always contiguous.

Response 200
{
  "data": [
    { "date": "Sep 04", "clicks": 22 },
    { "date": "Sep 05", "clicks": 11 }
  ],
  "meta": { "window_days": 7, "total": 33, "link_id": null }
}
GET /analytics/breakdown

Clicks grouped by one dimension, with each row's share of the total.

Query parameters

  • dimension string

    channel, referrer, device, browser, os, brand, variation or country. browser, os, brand and variation need a link_id.

    Required
  • link_id integer

    Scope to one link. Omit for the whole workspace.

    Optional
  • limit integer

    1–100. Defaults to 10.

    Optional
Response 200
{
  "data": [
    { "label": "Search", "value": 215, "share": 33.1 },
    { "label": "Social", "value": 201, "share": 30.9 },
    { "label": "Direct / none", "value": 142, "share": 21.8 }
  ],
  "meta": { "dimension": "channel", "link_id": 49, "total": 650 }
}
GET /events Paginated

The raw visit feed across every link in the workspace, newest first. This is the modern replacement for GET /visits: same data, standard envelope, limit and offset support.

Response 200
{
  "data": [
    {
      "id": 2691,
      "link_id": 49,
      "short_code": "spring",
      "occurred_at": "2026-09-10T22:45:33+00:00",
      "ip_address": "203.0.113.7",
      "referrer": "https://twitter.com/",
      "browser": "Chrome",
      "os": "iOS",
      "device_type": "mobile",
      "device_brand": "Apple",
      "country": "US",
      "variation": "control"
    }
  ],
  "meta": { "limit": 50, "returned": 50, "geo_included": true }
}

Webhooks

GET /hooks

Webhook endpoints registered on this account.

POST /hooks

Register an HTTPS endpoint to receive a JSON payload on every recorded click. Up to 25 per account. Failed deliveries are retried automatically.

JSON body

  • target_url

    Must be https://.

    Required
  • event

    Defaults to link_visited, which is currently the only event.

    Optional
POST cURL
curl -X POST https://letl.ink/api/v1/hooks \
  -H "X-API-KEY: YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"target_url": "https://example.com/hooks/clicks"}'
DELETE /hooks/{id}

Remove a webhook endpoint. POST /hooks/subscribe and DELETE /hooks/unsubscribe/{id} remain available for Zapier's REST Hook flow.

Legacy

GET /auth/me Legacy

The original connection test: returns your user id, email and workspace id/name as a flat object. GET /me returns the same identity plus your plan limits and feature flags, and is the better choice for new code.

GET cURL
curl -H "X-API-KEY: YOUR_TOKEN" \
  https://letl.ink/api/v1/auth/me
Response 200
{
  "id": 1,
  "email": "[email protected]",
  "workspace_id": 12,
  "workspace_name": "My Workspace"
}
GET /visits Legacy

The 50 most recent visits across the workspace as a bare JSON array — no envelope — using visit_id, visit_time, link_shortcode and visitor_* field names. This is what Zapier's REST Hook "perform list" step reads, so its shape is frozen. Use GET /events for new work.

POST /hooks/subscribe Zapier

REST Hook subscribe. Takes target_url and an optional event (default link_visited) and returns the created subscription flat, without an envelope. POST /hooks is the same operation in the standard shape.

DELETE /hooks/unsubscribe/{id} Zapier

REST Hook unsubscribe, returning {"success": true}. DELETE /hooks/{id} is the equivalent in the standard shape.

Errors

  • 400 invalid_request

    The body was not a JSON object, or a required field was missing.

  • 400 invalid_parameter

    A query parameter was malformed or outside its allowed set. details.parameter names the offender.

  • 401 unauthenticated

    No token was sent. Add X-API-KEY or Authorization: Bearer.

  • 401 invalid_token

    The token does not match any account. It may have been regenerated.

  • 403 plan_required

    The account's plan does not include this endpoint or feature. details.upgrade_url points at billing.

  • 403 forbidden

    Authenticated, but not permitted — for example a member attempting an owner-only action.

  • 404 not_found

    No such resource in this workspace. Ids from another workspace are reported as not found rather than forbidden.

  • 409 conflict

    The request collides with current state — the webhook limit is reached, or the account has no workspace yet.

  • 422

    Returned by POST /links/bulk when every row in the batch failed. Inspect meta.errors.

  • 429 rate_limited

    Over 600 requests in the last minute. Wait for Retry-After seconds.

The legacy GET /visits and GET /links/{id}/visits endpoints return a bare JSON array with their own field names and are frozen for the Zapier integrations already built on them. New code should use GET /events and GET /links/{id}/events, which use the standard envelope and support pagination.

Still have questions?

We're here to help. Reach out and we'll get back to you as soon as we can.