API reference · v1

Lnki.io API

Create and manage short links, QR codes and bulk imports from your own systems. The API works on the same data you see in the dashboard and follows the same rules, so anything you create here shows up there, and the other way round.

  • StyleREST · JSON
  • AuthAccess tokens
  • Rate limit5,000 requests / min
  • PlanBusiness

What v1 covers

Links

List, create, retrieve, update and archive short links, with custom back-halves, custom domains, folders, tags, UTM parameters, expiry dates and passwords.

QR codes

Create a QR code for a new or existing link, style its pattern, colours, logo and label, and download the drawing as SVG.

Imports

Create up to 500 links in one request and get the outcome of every row back.

Webhooks

Signed HTTP callbacks when links are created, updated, deleted or clicked, when a domain is connected, and more.

Analytics, domains and campaigns are managed in the dashboard. Links created through the API can still use them: pass a connected domain, a folder_id, tag_ids or a campaign.

Quickstart

Four steps from nothing to a working short link.

  1. Create an API key

    In the dashboard open API Keys and choose Create New API Key. The key starts with lnki_live_ and is shown once, so copy it somewhere safe straight away. API keys are available on the Business plan.

  2. Exchange the key for an access token

    Your key is only ever sent to the token endpoint. It answers with an access token that lasts one hour, and a refresh token for getting the next one.

    curl -X POST https://lnki.io/api/v1/oauth/token \
      -H "Content-Type: application/json" \
      -d '{
        "grant_type": "api_key",
        "api_key": "YOUR_API_KEY"
      }'
  3. Send your first request

    Create a short link by posting the long URL, with the access token as a Bearer token.

    curl -X POST https://lnki.io/api/v1/links \
      -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "destination_url": "https://example.com/summer-sale"
      }'
  4. Use the response

    You get back 201 Created and the new link object. Its short_host_path is the short link without the scheme. Prefix it with https:// and share it. Keep using the same access token until it expires, then refresh it.

Authentication

The API uses short-lived access tokens. You create a long-lived API key in the dashboard, exchange it for an access token, and send that token with every request. When it expires you use the refresh token that came with it to get a new pair, without sending the key again.

CredentialLooks likeLifetimeUsed for
API keylnki_live_…Until you revoke itSent only to POST /v1/oauth/token.
Access tokenlnki_at_…1 hourEvery API request, as a Bearer token.
Refresh tokenlnki_rt_…30 days, single useGetting the next access token at POST /v1/oauth/token.

Sending the token

Put the access token in the Authorization header of every request.

Authorization: Bearer YOUR_ACCESS_TOKEN

Token lifecycle

  1. Get a token once and keep it in memory or your secret store. Do not request one per API call: the token endpoint allows 30 requests a minute from one address, and a client needs one an hour.
  2. Refresh before it runs out. Compare the clock with expires_at, or refresh when a request answers 401 with the code token_expired, then repeat that request.
  3. Store the new refresh token each time. A refresh token works once; refreshing returns a new access token and a new refresh token, and the previous pair stops working.
  4. Start again from the API key if the refresh token has expired or been revoked.

Authentication errors

A request that cannot be authenticated answers 401 Unauthorized. The code tells you what to do next.

CodeMeaningWhat to do
missing_tokenNo Authorization header was sent.Send Bearer followed by an access token.
token_expiredThe access token is past its hour.Refresh, then repeat the request.
invalid_tokenThe token is unknown, was revoked, was replaced by a refresh, or its API key was revoked.Get a new token with your API key.
api_key_not_allowedAn API key was sent where an access token belongs.Exchange the key at the token endpoint first.

Keep keys and tokens on your server. Anyone holding one can manage every link in the workspace, so never ship them in a web page, a mobile app or a public repository. The API does not allow cross-origin browser requests. Revoking or regenerating an API key in the dashboard ends every token issued from it at once.

Get an access token

POST/v1/oauth/token

Exchanges an API key for an access token and a refresh token. This is the only endpoint that accepts an API key, and it takes no Authorization header.

Request body

Send JSON, or the same fields form-encoded.

ParameterTypeDescription
grant_typeRequiredstringapi_key.
api_keyRequiredstringYour API key, starting with lnki_live_.

Example request

curl -X POST https://lnki.io/api/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "api_key",
    "api_key": "YOUR_API_KEY"
  }'

Response

The response is sent with Cache-Control: no-store. The tokens are shown here only; Lnki.io keeps a one-way hash of each and cannot show them again.

FieldTypeDescription
access_tokenstringThe token to send with API requests.
token_typestringAlways Bearer.
expires_inintegerSeconds until the access token expires: 3600.
expires_atstringThe same moment as an ISO 8601 timestamp.
refresh_tokenstringUse once to get the next pair.
refresh_token_expires_inintegerSeconds until the refresh token expires: 2592000, 30 days.
refresh_token_expires_atstringThe same moment as an ISO 8601 timestamp.
rate_limitobjectYour allowance: limit requests per period seconds.
200 OK
{
  "access_token": "lnki_at_3b1f9c62d0a84e7f…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "expires_at": "2026-10-05T12:35:14.913Z",
  "refresh_token": "lnki_rt_be0be37c1d5a9f24…",
  "refresh_token_expires_in": 2592000,
  "refresh_token_expires_at": "2026-11-04T11:35:14.913Z",
  "rate_limit": {
    "limit": 5000,
    "period": 60
  }
}

Errors

StatusCodeCause
400invalid_requestgrant_type or api_key is missing, or the body is not valid JSON.
400unsupported_grant_typegrant_type is something other than api_key or refresh_token.
401invalid_clientThe API key is unknown or has been revoked.
403access_deniedThe workspace is not on the Business plan.
429rate_limitedMore than 30 token requests in a minute from one address.

Refresh an access token

POST/v1/oauth/token

Trades a refresh token for a new access token and a new refresh token. It works whether or not the access token has already expired.

Request body

ParameterTypeDescription
grant_typeRequiredstringrefresh_token.
refresh_tokenRequiredstringThe refresh token from your latest token response.

Example request

curl -X POST https://lnki.io/api/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "refresh_token",
    "refresh_token": "YOUR_REFRESH_TOKEN"
  }'

Response

200 OK with the same fields as getting a token. Save both new tokens: the access token and refresh token you used before stop working as soon as this response is sent.

A refresh token works once. If one that was already used is sent again, Lnki.io treats it as leaked and revokes every token that came from it, including the newest pair. Refresh from one place in your system, and if you ever see invalid_grant, get a fresh token with your API key.

Errors

StatusCodeCause
401invalid_grantThe refresh token is unknown, expired, already used or revoked, or its API key was revoked.
403access_deniedThe workspace is no longer on the Business plan.

Revoke a token

POST/v1/oauth/revoke

Ends a token pair before it expires, for example when you shut a server down. Send either the access token or the refresh token; both stop working.

Request body

ParameterTypeDescription
tokenRequiredstringAn access token or a refresh token.

Example request

curl -X POST https://lnki.io/api/v1/oauth/revoke \
  -H "Content-Type: application/json" \
  -d '{
    "token": "YOUR_ACCESS_TOKEN"
  }'

Response

Always 200 OK, also for a token that does not exist or was already revoked, so the call is safe to repeat.

200 OK
{
  "revoked": true
}

Requests

All endpoints are relative to this base URL.

https://lnki.io/api/v1
  • Send request bodies as JSON with Content-Type: application/json.
  • Responses are JSON, except a QR code's image. Timestamps are ISO 8601 in UTC, for example 2026-10-05T08:34:31.011Z.
  • Updates are partial: PATCH changes the fields you send and leaves the rest. PUT is accepted and behaves the same way.
  • The version is part of the path. Changes that would break an integration ship under a new version.

Rate limits

Limits are counted in one-minute windows that start on the minute. Every token issued from one API key shares that key's allowance.

LimitApplies toAllowance
API requestsEach API key, across all endpoints5,000 per minute
Link creationThe workspace, shared by the API and the dashboard60 new links per minute
ImportsThe workspace, shared by the API and the dashboard10 imports per minute, 500 rows each
Token requestsEach IP address30 per minute

Rate limit headers

Every response from a data endpoint tells you where you stand, so you can slow down before you are stopped.

HeaderValue
X-RateLimit-LimitRequests allowed in the current window.
X-RateLimit-RemainingRequests left in the current window.
X-RateLimit-ResetWhen the window ends, as a Unix time in seconds.
Retry-AfterSent with 429 only: seconds to wait before trying again.

Going over

A request over a limit answers 429 Too Many Requests. Wait for Retry-After seconds, then retry.

429 Too Many Requests
{
  "error": "Too many requests. Try again shortly.",
  "code": "rate_limited"
}

Errors

The API uses standard HTTP status codes. Every error body has an error message you can show or log and a code your program can branch on. Validation failures also include an errors array with one message per problem.

422 Unprocessable Content
{
  "error": "Slug has already been taken",
  "code": "validation_failed",
  "errors": [
    "Slug has already been taken"
  ]
}

Status codes

StatusCodeMeaning
200 201 204—Success. 201 when something was created, 204 with no body when something was deleted.
400invalid_json bad_requestThe request body is not valid JSON.
401missing_token invalid_token token_expired api_key_not_allowedThe request is not authenticated. See Authentication errors.
403forbiddenThe workspace's plan does not allow the action. The message says which plan does.
404not_foundThe record does not exist in this workspace, or an id you referenced was not found.
422validation_failedA field is missing or not acceptable. Read errors.
429rate_limitedA rate limit was reached. Wait for Retry-After seconds.
5xx—Something went wrong on our side. Retry with backoff.

Pagination

List endpoints return one page at a time, together with a meta object.

FieldTypeDescription
pageintegerQuery parameter. The page to return, starting at 1.
per_pageintegerQuery parameter. Links: 5, 10, 25 or 50. QR codes: 10, 25 or 50. Imports: any number from 1 to 100. A value outside the list falls back to the default.
meta.totalintegerNumber of records that match the request.
meta.total_cappedbooleanLinks and QR codes: true when a search matched more than 1,000 records and total stopped counting there.
meta.countsobjectLinks and QR codes: workspace totals for all, favorites and archived.
meta.cursorstringLinks and QR codes: opaque position of this page. Send it back as anchor with the next request to keep deep pages fast. Optional.

The QR code object

A QR code belongs to one short link and encodes that link's address, with a scan marker, so camera scans are counted apart from clicks on the link and the destination can change without reprinting. A link has at most one QR code.

FieldTypeDescription
idintegerUnique identifier of the QR code.
namestringDisplay name in the dashboard.
link_idintegerThe short link the code opens.
short_host_pathstringThat link without a scheme: lnki.io/ngczooh.
scan_urlstringThe address encoded in the code: the short link with the scan marker ?qr=1. Share short_host_path as a link, never this.
destination_urlstringWhere a scan ends up.
folderstring | nullName of the folder the code is filed under.
tag_idsinteger[]Ids of the code's tags. tags carries the same tags as { id, name }.
scansintegerCamera scans of the code by people. Clicks on the short link itself are not counted here; see link_clicks.
unique_scansintegerDistinct people among those scans.
link_clicksintegerEvery visit to the code's short link, scans included.
last_scanned_atstring | nullWhen the code was last scanned (ISO 8601).
favoritebooleanWhether the code's link is starred.
archivedbooleanWhether the code's link is archived.
expires_atstring | nullWhen the code stops working.
password_protectedbooleanWhether a scan asks for a password.
designobject | nullThe design options the code is drawn with. null for a plain black-and-white code.
created_atstringWhen the code was created.
svgstringThe drawn code as SVG markup. Included when a single code is returned, left out of lists.
QR code
{
  "id": 7315,
  "name": "Autumn menu",
  "link_id": 48306,
  "short_host_path": "lnki.io/ngczooh",
  "scan_url": "https://lnki.io/ngczooh?qr=1",
  "destination_url": "https://example.com/menu/autumn",
  "folder": "Print",
  "tag_ids": [],
  "tags": [],
  "scans": 0,
  "unique_scans": 0,
  "link_clicks": 0,
  "last_scanned_at": null,
  "favorite": false,
  "archived": false,
  "expires_at": null,
  "password_protected": false,
  "design": {
    "size": 600,
    "foreground_color": "#6D28D9",
    "eye_color": "#1F2A44",
    "background_color": "#FFFFFF",
    "pattern": "dots",
    "eyes": "rounded",
    "corners": "dot",
    "margin": 2,
    "error_correction": "Q",
    "logo": false,
    "label": {
      "text": "Scan for the menu",
      "font_size": 18
    }
  },
  "created_at": "2026-10-05T11:35:52.549Z",
  "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"600\" height=\"684\" viewBox=\"0 0 600 684\">…</svg>"
}

Design options

Send design when creating or updating a QR code to control how it is drawn. Every field is optional; the ones you leave out take the default.

FieldTypeDefaultDescription
sizeinteger500Width of the drawing in pixels, 16 to 4096.
foreground_colorstring#111827Colour of the pattern, as a hex colour.
eye_colorstringforegroundColour of the three corner markers.
corner_colorstringeye colourColour of the dot inside each corner marker.
background_colorstring#FFFFFFColour behind the code.
patternstringsquareShape of the modules: square, rounded, extra-rounded, dots, extra-dots, classy, classy-rounded, vertical, horizontal, diamond or star.
eyesstringsquareShape of the corner markers: square, rounded, circle, leaf or gradient.
cornersstringsquareShape of the dot inside them: square, rounded or dot.
margininteger1Quiet zone around the code, in modules, 0 to 64.
error_correctionstringML, M, Q or H. Higher levels survive more damage and make a denser code. Defaults to H when a logo is set.
logostring—An image for the centre, as a base64 data URL (PNG, JPEG, GIF, WebP or SVG) of up to 400 KB. Send null to remove it. Responses show true or false here instead of the image.
logo_sizenumber0.22Share of the code's width the logo covers, between 0 and 1.
labelobject—A caption under the code: text (up to 200 characters), and optionally font_size, color and background_color.

Keep strong contrast between foreground_color and background_color, and test the result with a phone camera before printing. A large logo or a low error_correction level makes a code harder to scan.

List QR codes

GET/v1/qr_codes

Returns the workspace's QR codes, newest first, one page at a time, without their drawings.

Query parameters

ParameterTypeDescription
qstringSearch in the code's name and in its link's back-half, title and destination.
tabstringall (default, not archived), favorites or archived.
folderstringOnly codes filed under this folder name.
tag_idsinteger[]Only codes carrying these tags, as a comma-separated list. tag_match chooses any (default) or all.
sortstringnewest (default) or oldest.
page per_page anchor—See Pagination. per_page is 10, 25 or 50.

Example request

curl "https://lnki.io/api/v1/qr_codes?per_page=25&q=menu" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response

200 OK
{
  "qr_codes": [
    {
      "id": 7315,
      "name": "Autumn menu",
      "link_id": 48306,
      "short_host_path": "lnki.io/ngczooh",
      "scan_url": "https://lnki.io/ngczooh?qr=1",
      "destination_url": "https://example.com/menu/autumn",
      "folder": "Print",
      "tag_ids": [],
      "tags": [],
      "scans": 0,
      "unique_scans": 0,
      "link_clicks": 0,
      "last_scanned_at": null,
      "favorite": false,
      "archived": false,
      "expires_at": null,
      "password_protected": false,
      "design": {
        "size": 600,
        "foreground_color": "#6D28D9",
        "eye_color": "#1F2A44",
        "background_color": "#FFFFFF",
        "pattern": "dots",
        "eyes": "rounded",
        "corners": "dot",
        "margin": 2,
        "error_correction": "Q",
        "logo": false,
        "label": {
          "text": "Scan for the menu",
          "font_size": 18
        }
      },
      "created_at": "2026-10-05T11:35:52.549Z"
    }
  ],
  "meta": {
    "total": 1,
    "total_capped": false,
    "page": 1,
    "per_page": 25,
    "total_pages": 1,
    "counts": {
      "all": 1,
      "favorites": 0,
      "archived": 0
    },
    "cursor": "eyJvIjowLCJuIjoxLCJmIjpb…"
  }
}

Create a QR code

POST/v1/qr_codes

Creates a QR code for a link you already have, or shortens a new URL and makes its code in one call. Send either link_id or destination_url.

Request body

ParameterTypeDescription
link_idintegerAn existing link to make the code for. Use this when you want to choose the link's back-half yourself: create the link first.
destination_urlstringA long URL to shorten. A new link with a generated back-half is created for the code.
domainstringWith destination_url: a connected domain for the new link. Defaults to the workspace's primary domain.
namestringDisplay name. Defaults to the link's title.
folderstringFolder name to file the code under.
tag_idsinteger[]Tags to attach, up to 10.
designobjectHow the code is drawn. See Design options.
expires_at expiry_daysstring · integerWhen the code's link stops redirecting.
passwordstringAsk people who scan the code for this password. At least 8 characters.

A link has one QR code. Creating a code for a link that already has one updates that code and returns it, so the request is safe to repeat.

Example request

curl -X POST https://lnki.io/api/v1/qr_codes \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "destination_url": "https://example.com/menu/autumn",
    "name": "Autumn menu",
    "folder": "Print",
    "design": {
      "foreground_color": "#6D28D9",
      "eye_color": "#1F2A44",
      "pattern": "dots",
      "eyes": "rounded",
      "corners": "dot",
      "size": 600,
      "label": {
        "text": "Scan for the menu"
      }
    }
  }'

Response

201 Created with the QR code object under qr_code, including its svg.

Common errors

StatusMessageCause
422Destination url can't be blankNeither link_id nor destination_url was sent.
422Design pattern is not validA design value is outside what the option accepts. Each bad field is named in errors, and nothing is created.
422Design colour is not a known optionA design field name is misspelt.
404Not found.The link_id does not exist in the workspace, or the link is archived.

Retrieve a QR code

GET/v1/qr_codes/{id}

Returns one QR code with its current scan counts and its drawing.

Example request

curl https://lnki.io/api/v1/qr_codes/7315 \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response

200 OK with { "qr_code": { … } }, the QR code object including svg.

Update a QR code

PATCH/v1/qr_codes/{id}

Changes a code's name, folder, tags, protection or design. The code keeps pointing at the same link, so codes already printed keep working.

Request body

SendEffect
name, folder, tag_idsReplace the value. "tag_ids": [] removes every tag.
designChanges only the design fields you name; the others stay as they are.
"design": nullRemoves the design, back to a plain black-and-white code.
password, expires_atChange the link's protection. "password": "" removes the password, "expires_at": null the expiry.

To change where a code leads, update its link's destination_url.

Example request

curl -X PATCH https://lnki.io/api/v1/qr_codes/7315 \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Autumn menu 2026",
    "design": {
      "background_color": "#F5F3FF"
    }
  }'

Response

200 OK with the updated QR code object, redrawn.

Download a QR code image

GET/v1/qr_codes/{id}/image

Returns the drawing itself as an SVG image, ready to save to a file or send to a printer. SVG stays sharp at any size.

Example request

curl https://lnki.io/api/v1/qr_codes/7315/image \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  --output autumn-menu.svg

Response

200 OK with Content-Type: image/svg+xml and the SVG as the body. The same markup is in the svg field of the QR code object.

Delete a QR code

DELETE/v1/qr_codes/{id}

Deletes the QR code. Its short link is kept and keeps redirecting, so a code that was already printed still works; delete the link to stop it.

Example request

curl -X DELETE https://lnki.io/api/v1/qr_codes/7315 \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response

204 No Content, with an empty body.

The import object

An import is one batch of links created together. It records what was asked for and what happened to every row.

FieldTypeDescription
idintegerUnique identifier of the import.
statusstringcompleted, completed_with_errors when at least one row failed while being created, processing or failed.
settingsobjectThe settings the import ran with.
summary.totalintegerRows received.
summary.createdintegerRows that became links.
summary.skipped_invalidintegerRows whose URL is not valid.
summary.skipped_duplicateintegerRows whose destination already has a link, in the workspace or earlier in the same request.
summary.skipped_slug_takenintegerRows whose back-half is already in use.
summary.failedintegerRows that could not be created for another reason.
created_atstringWhen the import was received.
rowsobject[]Returned by Retrieve an import: each row as sent, with its status (created, invalid, skipped or failed), an error_message and the link_id it became.
Import
{
  "id": 11,
  "status": "completed",
  "settings": {
    "domain": "lnki.io",
    "folder_id": null,
    "create_folders": true,
    "create_tags": true
  },
  "summary": {
    "total": 5,
    "created": 2,
    "skipped_invalid": 1,
    "skipped_duplicate": 1,
    "skipped_slug_taken": 1,
    "failed": 0
  },
  "error_message": null,
  "created_at": "2026-10-05T11:36:07.499Z",
  "updated_at": "2026-10-05T11:36:07.754Z"
}

Create an import

POST/v1/imports

Creates up to 500 links in one request. The request returns when every row has been processed; send larger sets as several imports.

Request body

ParameterTypeDescription
rowsRequiredobject[]The links to create, 1 to 500.
rows[].destination_urlRequiredstringThe long URL. It must start with http:// or https://.
rows[].slugstringBack-half for the link. When omitted, one is taken from the last part of the URL's path, or generated.
rows[].titlestringDisplay name in the dashboard.
rows[].folderstringName of a folder to put the link in.
rows[].tagsstring[]Names of tags to attach. A comma-separated string works too.
settings.domainstringA connected domain for every link. Defaults to the workspace's primary domain.
settings.folder_idintegerA folder for rows that do not name one.
settings.create_foldersbooleanCreate folders named in rows that do not exist yet. Default false: unknown folder names are ignored.
settings.create_tagsbooleanCreate tags named in rows that do not exist yet. Default false: unknown tag names are ignored.

One bad row does not stop the others. Rows that are not valid, repeat a destination or ask for a back-half that is taken are skipped and reported, and the rest are created.

Example request

curl -X POST https://lnki.io/api/v1/imports \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "rows": [
      {
        "destination_url": "https://example.com/products/wool-coat",
        "slug": "wool-coat",
        "title": "Wool coat",
        "folder": "Catalogue",
        "tags": [
          "autumn",
          "coats"
        ]
      },
      {
        "destination_url": "https://example.com/products/leather-boots",
        "title": "Leather boots",
        "folder": "Catalogue",
        "tags": [
          "autumn",
          "boots"
        ]
      },
      {
        "destination_url": "not a url"
      },
      {
        "destination_url": "https://example.com/products/wool-coat"
      },
      {
        "destination_url": "https://example.com/products/scarf",
        "slug": "summer-sale"
      }
    ],
    "settings": {
      "create_folders": true,
      "create_tags": true
    }
  }'

Response

201 Created with the import, the links that were made and, in errors, the row number and reason for any row that failed while being created. Skipped rows are counted in summary; retrieve the import to read the reason for each.

201 Created
{
  "import": {
    "id": 11,
    "status": "completed",
    "settings": {
      "domain": "lnki.io",
      "folder_id": null,
      "create_folders": true,
      "create_tags": true
    },
    "summary": {
      "total": 5,
      "created": 2,
      "skipped_invalid": 1,
      "skipped_duplicate": 1,
      "skipped_slug_taken": 1,
      "failed": 0
    },
    "error_message": null,
    "created_at": "2026-10-05T11:36:07.499Z",
    "updated_at": "2026-10-05T11:36:07.754Z"
  },
  "links": [
    {
      "id": 48310,
      "row": 1,
      "slug": "wool-coat",
      "destination_url": "https://example.com/products/wool-coat",
      "short_host_path": "lnki.io/wool-coat",
      "status": "created"
    },
    {
      "id": 48311,
      "row": 2,
      "slug": "leather-boots",
      "destination_url": "https://example.com/products/leather-boots",
      "short_host_path": "lnki.io/leather-boots",
      "status": "created"
    }
  ],
  "errors": []
}

Common errors

StatusMessageCause
422rows must be a list of links to create.rows is missing or empty.
422Maximum 500 rows allowed.Split the rows over several imports.
429Too many requests. Try again shortly.More than 10 imports in the current minute.

Retrieve an import

GET/v1/imports/{id}

Returns an import with the outcome of each row.

Query parameters

ParameterTypeDescription
rowsstringissues returns only the rows that did not become links. false leaves the rows out. By default every row is returned.

Example request

curl "https://lnki.io/api/v1/imports/11?rows=issues" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response

200 OK
{
  "import": {
    "id": 11,
    "status": "completed",
    "settings": {
      "domain": "lnki.io",
      "folder_id": null,
      "create_folders": true,
      "create_tags": true
    },
    "summary": {
      "total": 5,
      "created": 2,
      "skipped_invalid": 1,
      "skipped_duplicate": 1,
      "skipped_slug_taken": 1,
      "failed": 0
    },
    "error_message": null,
    "created_at": "2026-10-05T11:36:07.499Z",
    "updated_at": "2026-10-05T11:36:07.754Z",
    "rows": [
      {
        "row": 3,
        "destination_url": "not a url",
        "slug": null,
        "title": null,
        "folder": null,
        "tags": null,
        "status": "invalid",
        "error_message": "URL must start with http:// or https://",
        "link_id": null
      },
      {
        "row": 4,
        "destination_url": "https://example.com/products/wool-coat",
        "slug": "wool-coat",
        "title": null,
        "folder": null,
        "tags": null,
        "status": "skipped",
        "error_message": "Duplicate URL in import file",
        "link_id": null
      },
      {
        "row": 5,
        "destination_url": "https://example.com/products/scarf",
        "slug": "summer-sale",
        "title": null,
        "folder": null,
        "tags": null,
        "status": "skipped",
        "error_message": "Back-half is already used by a link.",
        "link_id": null
      }
    ]
  }
}

List imports

GET/v1/imports

Returns the workspace's imports, newest first, including ones started from the dashboard.

Query parameters

ParameterTypeDescription
statusstringOnly imports with this status, for example completed_with_errors.
page per_pageintegerper_page is 1 to 100 and defaults to 20.

Example request

curl "https://lnki.io/api/v1/imports?per_page=20" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response

200 OK
{
  "imports": [
    {
      "id": 11,
      "status": "completed",
      "settings": {
        "domain": "lnki.io",
        "folder_id": null,
        "create_folders": true,
        "create_tags": true
      },
      "summary": {
        "total": 5,
        "created": 2,
        "skipped_invalid": 1,
        "skipped_duplicate": 1,
        "skipped_slug_taken": 1,
        "failed": 0
      },
      "error_message": null,
      "created_at": "2026-10-05T11:36:07.499Z",
      "updated_at": "2026-10-05T11:36:07.754Z"
    }
  ],
  "meta": {
    "total": 1,
    "page": 1,
    "per_page": 20
  }
}

Webhooks

A webhook sends an HTTP POST to your server when something happens in your workspace, so you do not have to poll the API. Add one under Webhooks in the dashboard: give it a name, your endpoint URL and the events you want.

  • The endpoint must be a public https:// URL.
  • Each webhook has its own signing secret, starting with whsec_. It is shown once, when the webhook is created.
  • Choose Test webhook in the webhook's menu to receive a webhook.test event before going live.
  • Answer with any 2xx status within 10 seconds. Do slow work after you have responded.

Events & payloads

EventSent whendata contains
link.createdA short link is created, in the dashboard or through the API.The link object.
link.updatedA link is edited.The link object.
link.deletedA link is archived or deleted.The link object.
link.clickedA person opens a short link. Delivered in batches as link.clicked.batch.count and a clicks array.
domain.connectedA custom domain passes DNS verification.id, host, status, connected_at.
bio.clickedA link on a Link in Bio page is clicked.The bio page, the link and the click.
api_key.createdA new API key is created.id, name, token_prefix, created_at.

Envelope

Every delivery has the same outer shape. event_id identifies the event; data depends on the event type.

link.created
{
  "event": "link.created",
  "event_id": "evt_8f2k9m3n4b7c1d5e",
  "created_at": "2026-10-05T08:34:31.120Z",
  "data": {
    "id": 48291,
    "slug": "summer-sale",
    "domain": "lnki.io",
    "destination_url": "https://example.com/summer-sale",
    "title": "Summer sale landing page",
    "short_host_path": "lnki.io/summer-sale",
    "archived": false,
    "click_count": 0,
    "favorite": false,
    "folder_id": null,
    "utm_campaign_id": null,
    "utm_channel_id": null,
    "expires_at": null,
    "expired": false,
    "password_protected": false,
    "created_at": "2026-10-05T08:34:31.011Z",
    "unique_click_count": 0,
    "tag_ids": [],
    "tags": [],
    "tag_id": null
  }
}

Click batches

Clicks can arrive quickly, so they are grouped: one delivery carries up to 50 clicks, and a batch is sent at least every 5 seconds while clicks are coming in. Subscribe to link.clicked; the deliveries you receive are named link.clicked.batch.

link.clicked.batch
{
  "event": "link.clicked.batch",
  "event_id": "evt_1c7d9e2f6a4b8c3d",
  "created_at": "2026-10-05T10:30:05.412Z",
  "data": {
    "batch": true,
    "count": 1,
    "clicks": [
      {
        "link_id": 48291,
        "short_url": "lnki.io/summer-sale",
        "destination_url": "https://example.com/summer-sale",
        "clicked_at": "2026-10-05T10:30:00.000Z",
        "country_code": "US",
        "referrer_host": "twitter.com",
        "device": "mobile"
      }
    ]
  }
}

Verify signatures

Anyone who learns your endpoint URL could post to it, so check that each delivery really came from Lnki.io. Every request is signed with the webhook's secret and carries these headers:

HeaderValue
X-Lnki-Signaturesha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with the webhook secret.
X-Lnki-EventThe event type, for example link.created.
X-Lnki-Delivery-KeyIdentifies one event sent to one webhook. It stays the same across retries, so use it to ignore duplicates.
X-Lnki-Delivery-IdIdentifies this single attempt.
X-Lnki-AttemptThe attempt number, starting at 1.

Compute the same HMAC over the raw body bytes, before any JSON parsing, and compare it to the header with a constant-time comparison.

import crypto from "node:crypto"
import express from "express"

const app = express()

// Keep the raw body: the signature is computed over the exact bytes sent.
app.post("/webhooks/lnki", express.raw({ type: "application/json" }), (req, res) => {
  const expected = "sha256=" + crypto
    .createHmac("sha256", process.env.LNKI_WEBHOOK_SECRET)
    .update(req.body)
    .digest("hex")
  const received = req.get("X-Lnki-Signature") || ""

  const valid = expected.length === received.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))
  if (!valid) return res.sendStatus(401)

  const event = JSON.parse(req.body)
  // Handle event.event and event.data here.
  res.sendStatus(200)
})
import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)


@app.post("/webhooks/lnki")
def lnki_webhook():
    # request.get_data() is the raw body: sign the exact bytes sent.
    expected = "sha256=" + hmac.new(
        os.environ["LNKI_WEBHOOK_SECRET"].encode(),
        request.get_data(),
        hashlib.sha256,
    ).hexdigest()
    received = request.headers.get("X-Lnki-Signature", "")

    if not hmac.compare_digest(expected, received):
        abort(401)

    event = request.get_json()
    # Handle event["event"] and event["data"] here.
    return "", 200
require "openssl"
require "json"
require "sinatra"

post "/webhooks/lnki" do
  # The raw body: sign the exact bytes sent.
  body = request.body.read
  expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("LNKI_WEBHOOK_SECRET"), body)
  received = request.env["HTTP_X_LNKI_SIGNATURE"].to_s

  halt 401 unless Rack::Utils.secure_compare(expected, received)

  event = JSON.parse(body)
  # Handle event["event"] and event["data"] here.
  status 200
end
<?php
// The raw body: sign the exact bytes sent.
$body = file_get_contents("php://input");
$expected = "sha256=" . hash_hmac("sha256", $body, getenv("LNKI_WEBHOOK_SECRET"));
$received = $_SERVER["HTTP_X_LNKI_SIGNATURE"] ?? "";

if (!hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
// Handle $event["event"] and $event["data"] here.
http_response_code(200);

Delivery & retries

A delivery succeeds when your endpoint answers with a 2xx status within 10 seconds.

Your endpointWhat happens
Answers 2xxDelivered. Nothing more is sent for that event.
Times out, cannot be reached, or answers 429 or 5xxRetried, up to 5 attempts in total, waiting 30 seconds, 1 minute, 2 minutes and 4 minutes between them.
Answers any other 4xxNot retried. The delivery is recorded as failed.
  • Expect duplicates. A retry can arrive after your server already processed the event but answered too late. Store X-Lnki-Delivery-Key and skip keys you have seen.
  • Do not rely on order. Events are sent as they happen, but retries can make them arrive out of order. Use created_at if order matters.
  • Failing webhooks are stopped. After 5 events in a row fail every attempt, the webhook is marked Failed and receives nothing until you resume it in the dashboard. You get a notification when that happens.
  • Every attempt, with its response code and duration, is listed in the webhook's delivery log.

Plans & limits

What each plan includes for developers. See Pricing for everything else.

FeatureTrialStarterProBusiness
API access———Included
API rate limit———5,000 requests / min per key
Rows per import request———500
Webhooks—510Unlimited
Custom back-halves—IncludedIncludedIncluded
Custom domains13UnlimitedUnlimited
Links5UnlimitedUnlimitedUnlimited