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.
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.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" }'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" }'Use the response
You get back
201 Createdand the new link object. Itsshort_host_pathis the short link without the scheme. Prefix it withhttps://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.
| Credential | Looks like | Lifetime | Used for |
|---|---|---|---|
| API key | lnki_live_… | Until you revoke it | Sent only to POST /v1/oauth/token. |
| Access token | lnki_at_… | 1 hour | Every API request, as a Bearer token. |
| Refresh token | lnki_rt_… | 30 days, single use | Getting 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
- 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.
- Refresh before it runs out. Compare the clock with
expires_at, or refresh when a request answers401with the codetoken_expired, then repeat that request. - 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.
- 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.
| Code | Meaning | What to do |
|---|---|---|
missing_token | No Authorization header was sent. | Send Bearer followed by an access token. |
token_expired | The access token is past its hour. | Refresh, then repeat the request. |
invalid_token | The 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_allowed | An 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
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.
| Parameter | Type | Description |
|---|---|---|
grant_typeRequired | string | api_key. |
api_keyRequired | string | Your 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.
| Field | Type | Description |
|---|---|---|
access_token | string | The token to send with API requests. |
token_type | string | Always Bearer. |
expires_in | integer | Seconds until the access token expires: 3600. |
expires_at | string | The same moment as an ISO 8601 timestamp. |
refresh_token | string | Use once to get the next pair. |
refresh_token_expires_in | integer | Seconds until the refresh token expires: 2592000, 30 days. |
refresh_token_expires_at | string | The same moment as an ISO 8601 timestamp. |
rate_limit | object | Your allowance: limit requests per period seconds. |
{
"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
| Status | Code | Cause |
|---|---|---|
400 | invalid_request | grant_type or api_key is missing, or the body is not valid JSON. |
400 | unsupported_grant_type | grant_type is something other than api_key or refresh_token. |
401 | invalid_client | The API key is unknown or has been revoked. |
403 | access_denied | The workspace is not on the Business plan. |
429 | rate_limited | More than 30 token requests in a minute from one address. |
Refresh an access 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
| Parameter | Type | Description |
|---|---|---|
grant_typeRequired | string | refresh_token. |
refresh_tokenRequired | string | The 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
| Status | Code | Cause |
|---|---|---|
401 | invalid_grant | The refresh token is unknown, expired, already used or revoked, or its API key was revoked. |
403 | access_denied | The workspace is no longer on the Business plan. |
Revoke a token
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
| Parameter | Type | Description |
|---|---|---|
tokenRequired | string | An 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.
{
"revoked": true
}Requests
All endpoints are relative to this base URL.
- 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:
PATCHchanges the fields you send and leaves the rest.PUTis 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.
| Limit | Applies to | Allowance |
|---|---|---|
| API requests | Each API key, across all endpoints | 5,000 per minute |
| Link creation | The workspace, shared by the API and the dashboard | 60 new links per minute |
| Imports | The workspace, shared by the API and the dashboard | 10 imports per minute, 500 rows each |
| Token requests | Each IP address | 30 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.
| Header | Value |
|---|---|
X-RateLimit-Limit | Requests allowed in the current window. |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | When the window ends, as a Unix time in seconds. |
Retry-After | Sent 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.
{
"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.
{
"error": "Slug has already been taken",
"code": "validation_failed",
"errors": [
"Slug has already been taken"
]
}Status codes
| Status | Code | Meaning |
|---|---|---|
200 201 204 | — | Success. 201 when something was created, 204 with no body when something was deleted. |
400 | invalid_json bad_request | The request body is not valid JSON. |
401 | missing_token invalid_token token_expired api_key_not_allowed | The request is not authenticated. See Authentication errors. |
403 | forbidden | The workspace's plan does not allow the action. The message says which plan does. |
404 | not_found | The record does not exist in this workspace, or an id you referenced was not found. |
422 | validation_failed | A field is missing or not acceptable. Read errors. |
429 | rate_limited | A 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.
| Field | Type | Description |
|---|---|---|
page | integer | Query parameter. The page to return, starting at 1. |
per_page | integer | Query 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.total | integer | Number of records that match the request. |
meta.total_capped | boolean | Links and QR codes: true when a search matched more than 1,000 records and total stopped counting there. |
meta.counts | object | Links and QR codes: workspace totals for all, favorites and archived. |
meta.cursor | string | Links and QR codes: opaque position of this page. Send it back as anchor with the next request to keep deep pages fast. Optional. |
The link object
Every endpoint returns links in this shape.
| Field | Type | Description |
|---|---|---|
id | integer | Unique identifier of the link. |
slug | string | The back-half of the short link. |
domain | string | The domain the short link lives on. |
short_host_path | string | domain and slug joined, without a scheme: lnki.io/summer-sale. |
destination_url | string | Where the short link redirects, including any UTM parameters. |
title | string | Display name. Defaults to the destination's host. |
archived | boolean | true once the link has been deleted through the API or archived in the dashboard. |
favorite | boolean | Whether the link is starred. |
click_count | integer | Clicks from people. Bots and link previews are not counted. |
unique_click_count | integer | Distinct visitors among those clicks. |
folder_id | integer | null | The folder holding the link. When set, a folder object with id and name is included too. |
tag_ids | integer[] | Ids of the link's tags. |
tags | object[] | The same tags as { id, name }. |
tag_id | integer | null | The first tag only. Kept for older integrations; use tag_ids. |
utm_campaign_id | integer | null | The campaign the link belongs to. |
utm_channel_id | integer | null | The campaign channel the link belongs to. |
expires_at | string | null | When the link stops redirecting. |
expired | boolean | true once expires_at has passed. |
password_protected | boolean | Whether visitors must enter a password. The password itself is never returned. |
created_at | string | When the link was created. |
{
"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
}
List links
Returns the workspace's links, newest first, one page at a time. Archived links are left out unless you ask for them with tab=archived.
Query parameters
| Parameter | Type | Description |
|---|---|---|
q | string | Search in the title, back-half and destination URL. |
tab | string | all (default, not archived), favorites or archived. |
status | string | all (default), active for links that have not expired, or expired. |
sort | string | created-desc (default), created-asc, clicks-desc or clicks-asc. |
folder_id | integer | Only links in this folder. |
tag_ids | integer[] | Only links carrying these tags, as a comma-separated list. Up to 10. |
tag_match | string | any (default) or all of the given tags. |
utm | boolean | true for links with UTM parameters or a campaign only. |
utm_campaign_id | integer | Only links in this campaign. |
utm_channel_id | integer | Only links in this campaign channel. |
page per_page anchor | — | See Pagination. |
Values the API does not recognise are ignored and the default is used instead, so a typo in sort returns the newest links rather than an error.
Example request
curl "https://lnki.io/api/v1/links?per_page=25&sort=clicks-desc" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Response
{
"links": [
{
"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": 1284,
"favorite": true,
"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": 1022,
"tag_ids": [],
"tags": [],
"tag_id": null
}
],
"meta": {
"total": 1,
"total_capped": false,
"page": 1,
"per_page": 25,
"counts": {
"all": 1,
"favorites": 1,
"archived": 0
},
"cursor": "eyJvIjowLCJuIjoxLCJmIjpb…"
}
}
Create a link
Shortens a URL. Only destination_url is required; fields you leave out take the defaults set for your workspace and domain.
Request body
| Parameter | Type | Description |
|---|---|---|
destination_urlRequired | string | The long URL. Must be http or https; https:// is added when the scheme is missing. It cannot point at a short link domain. |
slug | string | Custom back-half: 2 to 64 characters, lowercase letters, digits, - and _, starting with a letter or digit. Must be unique on its domain. A random one is generated when omitted. |
domain | string | A domain connected to the workspace, for example go.yourbrand.com. Defaults to the workspace's primary domain. |
title | string | Display name in the dashboard. Defaults to the destination's host. |
favorite | boolean | Star the link. |
folder_id | integer | Put the link in a folder. |
tag_ids | integer[] | Tags to attach, up to 10. |
utm_source utm_medium utm_content utm_term | string | UTM parameters to add to the destination URL. |
utm_campaign_id utm_channel_id | integer | Attach the link to a campaign and channel; their UTM values are written to the destination URL. |
expires_at | string | ISO 8601 date and time after which the link stops redirecting. |
expiry_days | integer | Alternative to expires_at: expire this many days from now. |
password | string | Ask visitors for this password before redirecting. At least 8 characters. |
Folder, tag, campaign and channel ids are the ones the dashboard uses. Every link response includes the ids it carries, so you can read them from a link that is already organised the way you want.
Example request
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",
"slug": "summer-sale",
"title": "Summer sale landing page",
"utm_source": "newsletter",
"utm_medium": "email"
}'
Response
{
"link": {
"id": 48291,
"slug": "summer-sale",
"domain": "lnki.io",
"destination_url": "https://example.com/summer-sale?utm_source=newsletter&utm_medium=email",
"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
}
}
Common errors
| Status | Message | Cause |
|---|---|---|
422 | Destination url must be a valid http or https URL | The URL is missing, malformed or uses another scheme. |
422 | Slug has already been taken | Another link on the same domain uses that back-half. |
422 | Slug is reserved. | The back-half is a word the site itself uses, such as pricing or login. |
422 | Domain is not available for this workspace. | The domain is not connected to the workspace. |
422 | Password is too short (minimum is 8 characters) | — |
404 | Not found. | The folder_id, campaign or channel does not exist in the workspace. |
429 | Too many requests. Try again shortly. | More than 60 links created in the current minute. |
Retrieve a link
Returns one link by its id, with its current click counts. Archived links can be retrieved too.
Example request
curl https://lnki.io/api/v1/links/48291 \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Response
200 OK with { "link": { … } }, the link object. An id that does not belong to the workspace returns 404.
Update a link
Changes the fields you send and leaves the rest as they are. PUT is accepted as well and behaves the same way.
Request body
Any of the create parameters. A few values have a special meaning:
| Send | Effect |
|---|---|
"slug" or "domain" | Moves the short link. The old address stops redirecting straight away. |
"password": "" | Removes the password. |
"expires_at": null | Removes the expiry date. |
"folder_id": null | Takes the link out of its folder. |
"tag_ids": [] | Removes every tag. A non-empty list replaces the current tags. |
Example request
curl -X PATCH https://lnki.io/api/v1/links/48291 \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Summer sale 2026",
"favorite": true,
"expiry_days": 30
}'
Response
200 OK with the updated link object under link. Validation errors return 422, exactly as when creating.
Delete a link
Archives the link. It stops redirecting immediately and moves to the Archived tab of My Links, where it can be restored or removed for good. Its click history is kept.
Example request
curl -X DELETE https://lnki.io/api/v1/links/48291 \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Response
200 OK with the link under link, now with "archived": true. Deleting a link that is already archived returns the same response.
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.
| Field | Type | Description |
|---|---|---|
id | integer | Unique identifier of the QR code. |
name | string | Display name in the dashboard. |
link_id | integer | The short link the code opens. |
short_host_path | string | That link without a scheme: lnki.io/ngczooh. |
scan_url | string | The address encoded in the code: the short link with the scan marker ?qr=1. Share short_host_path as a link, never this. |
destination_url | string | Where a scan ends up. |
folder | string | null | Name of the folder the code is filed under. |
tag_ids | integer[] | Ids of the code's tags. tags carries the same tags as { id, name }. |
scans | integer | Camera scans of the code by people. Clicks on the short link itself are not counted here; see link_clicks. |
unique_scans | integer | Distinct people among those scans. |
link_clicks | integer | Every visit to the code's short link, scans included. |
last_scanned_at | string | null | When the code was last scanned (ISO 8601). |
favorite | boolean | Whether the code's link is starred. |
archived | boolean | Whether the code's link is archived. |
expires_at | string | null | When the code stops working. |
password_protected | boolean | Whether a scan asks for a password. |
design | object | null | The design options the code is drawn with. null for a plain black-and-white code. |
created_at | string | When the code was created. |
svg | string | The drawn code as SVG markup. Included when a single code is returned, left out of lists. |
{
"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.
| Field | Type | Default | Description |
|---|---|---|---|
size | integer | 500 | Width of the drawing in pixels, 16 to 4096. |
foreground_color | string | #111827 | Colour of the pattern, as a hex colour. |
eye_color | string | foreground | Colour of the three corner markers. |
corner_color | string | eye colour | Colour of the dot inside each corner marker. |
background_color | string | #FFFFFF | Colour behind the code. |
pattern | string | square | Shape of the modules: square, rounded, extra-rounded, dots, extra-dots, classy, classy-rounded, vertical, horizontal, diamond or star. |
eyes | string | square | Shape of the corner markers: square, rounded, circle, leaf or gradient. |
corners | string | square | Shape of the dot inside them: square, rounded or dot. |
margin | integer | 1 | Quiet zone around the code, in modules, 0 to 64. |
error_correction | string | M | L, M, Q or H. Higher levels survive more damage and make a denser code. Defaults to H when a logo is set. |
logo | string | — | 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_size | number | 0.22 | Share of the code's width the logo covers, between 0 and 1. |
label | object | — | 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
Returns the workspace's QR codes, newest first, one page at a time, without their drawings.
Query parameters
| Parameter | Type | Description |
|---|---|---|
q | string | Search in the code's name and in its link's back-half, title and destination. |
tab | string | all (default, not archived), favorites or archived. |
folder | string | Only codes filed under this folder name. |
tag_ids | integer[] | Only codes carrying these tags, as a comma-separated list. tag_match chooses any (default) or all. |
sort | string | newest (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
{
"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
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
| Parameter | Type | Description |
|---|---|---|
link_id | integer | An 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_url | string | A long URL to shorten. A new link with a generated back-half is created for the code. |
domain | string | With destination_url: a connected domain for the new link. Defaults to the workspace's primary domain. |
name | string | Display name. Defaults to the link's title. |
folder | string | Folder name to file the code under. |
tag_ids | integer[] | Tags to attach, up to 10. |
design | object | How the code is drawn. See Design options. |
expires_at expiry_days | string · integer | When the code's link stops redirecting. |
password | string | Ask 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
| Status | Message | Cause |
|---|---|---|
422 | Destination url can't be blank | Neither link_id nor destination_url was sent. |
422 | Design pattern is not valid | A design value is outside what the option accepts. Each bad field is named in errors, and nothing is created. |
422 | Design colour is not a known option | A design field name is misspelt. |
404 | Not found. | The link_id does not exist in the workspace, or the link is archived. |
Retrieve a QR code
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
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
| Send | Effect |
|---|---|
name, folder, tag_ids | Replace the value. "tag_ids": [] removes every tag. |
design | Changes only the design fields you name; the others stay as they are. |
"design": null | Removes the design, back to a plain black-and-white code. |
password, expires_at | Change 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
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
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.
| Field | Type | Description |
|---|---|---|
id | integer | Unique identifier of the import. |
status | string | completed, completed_with_errors when at least one row failed while being created, processing or failed. |
settings | object | The settings the import ran with. |
summary.total | integer | Rows received. |
summary.created | integer | Rows that became links. |
summary.skipped_invalid | integer | Rows whose URL is not valid. |
summary.skipped_duplicate | integer | Rows whose destination already has a link, in the workspace or earlier in the same request. |
summary.skipped_slug_taken | integer | Rows whose back-half is already in use. |
summary.failed | integer | Rows that could not be created for another reason. |
created_at | string | When the import was received. |
rows | object[] | 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. |
{
"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
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
| Parameter | Type | Description |
|---|---|---|
rowsRequired | object[] | The links to create, 1 to 500. |
rows[].destination_urlRequired | string | The long URL. It must start with http:// or https://. |
rows[].slug | string | Back-half for the link. When omitted, one is taken from the last part of the URL's path, or generated. |
rows[].title | string | Display name in the dashboard. |
rows[].folder | string | Name of a folder to put the link in. |
rows[].tags | string[] | Names of tags to attach. A comma-separated string works too. |
settings.domain | string | A connected domain for every link. Defaults to the workspace's primary domain. |
settings.folder_id | integer | A folder for rows that do not name one. |
settings.create_folders | boolean | Create folders named in rows that do not exist yet. Default false: unknown folder names are ignored. |
settings.create_tags | boolean | Create 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.
{
"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
| Status | Message | Cause |
|---|---|---|
422 | rows must be a list of links to create. | rows is missing or empty. |
422 | Maximum 500 rows allowed. | Split the rows over several imports. |
429 | Too many requests. Try again shortly. | More than 10 imports in the current minute. |
Retrieve an import
Returns an import with the outcome of each row.
Query parameters
| Parameter | Type | Description |
|---|---|---|
rows | string | issues 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
{
"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
Returns the workspace's imports, newest first, including ones started from the dashboard.
Query parameters
| Parameter | Type | Description |
|---|---|---|
status | string | Only imports with this status, for example completed_with_errors. |
page per_page | integer | per_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
{
"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.testevent before going live. - Answer with any
2xxstatus within 10 seconds. Do slow work after you have responded.
Events & payloads
| Event | Sent when | data contains |
|---|---|---|
link.created | A short link is created, in the dashboard or through the API. | The link object. |
link.updated | A link is edited. | The link object. |
link.deleted | A link is archived or deleted. | The link object. |
link.clicked | A person opens a short link. Delivered in batches as link.clicked.batch. | count and a clicks array. |
domain.connected | A custom domain passes DNS verification. | id, host, status, connected_at. |
bio.clicked | A link on a Link in Bio page is clicked. | The bio page, the link and the click. |
api_key.created | A 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.
{
"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.
{
"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:
| Header | Value |
|---|---|
X-Lnki-Signature | sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with the webhook secret. |
X-Lnki-Event | The event type, for example link.created. |
X-Lnki-Delivery-Key | Identifies one event sent to one webhook. It stays the same across retries, so use it to ignore duplicates. |
X-Lnki-Delivery-Id | Identifies this single attempt. |
X-Lnki-Attempt | The 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 endpoint | What happens |
|---|---|
Answers 2xx | Delivered. Nothing more is sent for that event. |
Times out, cannot be reached, or answers 429 or 5xx | Retried, up to 5 attempts in total, waiting 30 seconds, 1 minute, 2 minutes and 4 minutes between them. |
Answers any other 4xx | Not 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-Keyand 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_atif 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.
| Feature | Trial | Starter | Pro | Business |
|---|---|---|---|---|
| API access | — | — | — | Included |
| API rate limit | — | — | — | 5,000 requests / min per key |
| Rows per import request | — | — | — | 500 |
| Webhooks | — | 5 | 10 | Unlimited |
| Custom back-halves | — | Included | Included | Included |
| Custom domains | 1 | 3 | Unlimited | Unlimited |
| Links | 5 | Unlimited | Unlimited | Unlimited |