Introduction
All endpoints live under one base URL and are served over HTTPS only:
- Base URL:
https://bito.li/api - Request bodies are JSON. Send
Content-Type: application/json. A body that is not valid JSON is treated as empty. - Responses are JSON (
application/json; charset=UTF-8), exceptGET /links/create?format=simple, which returns plain text. - Timestamps are ISO 8601 strings in UTC, for example
2026-10-25T09:41:12.000Z. - Short links created through the API redirect at
https://bito.li/{shortCode}and links created on lnk.ink work at bito.li too.
The API does not send CORS headers, so browsers block calls from other websites. Call it from your server, a script, a mobile app or a backend function instead.
Authentication
There are no API keys. Creating links is anonymous, and each link is protected by its own secret code instead of an account.
- When you create a link with
controlMode: "full"(the default), the response contains asecretCodesuch ass-3f9-a1c-07b. It is returned only once. - Pass it as
secret(query) to read statistics, or assecretCode(JSON body) to update or delete the link. - Anyone who has the secret code can manage the link, so treat it like a password. Never put it in a public page or a URL you share.
Signed-in users of the bito.li website are identified by a short-lived Bearer access token. Links created with that token belong to the account, and the owner can read stats, edit and delete them without a secret code. This token is meant for the website and its apps; there is no supported way to issue long-lived tokens for integrations yet, so build integrations on secret codes.
Lost secret codes cannot be recovered or reset. If you need to manage many links, consider creating them while signed in on the website, or import them into an account using their secret codes.
Control modes
Every link has a controlMode that decides who can see its statistics and whether it can be changed later. It is set when the link is created and cannot be changed afterwards.
| Mode | Secret code | Statistics | Edit / delete | Linked to account |
|---|---|---|---|---|
full | Returned on creation | With the secret code, or the owner | Yes, with the secret code or as the owner | Yes, when created while signed in |
statsOnly | None | Public (or with the link password, if set) | No | No |
none | None | Not available | No | No |
Rate limits
Link creation (POST /links and GET /links/create) is limited per client IP address. The limits are shared with lnk.ink, so switching domains does not reset them.
- Burst limit: 120 requests in any rolling 10-minute window. Every attempt counts, including requests that fail validation. Exceeding it returns
429withrateLimit.tooManyRequests. - Daily cap: 300 new links per IP address per UTC day. Requests made by an account that is at least 7 days old may create up to 2,000. Exceeding it returns
429withrateLimit.dailyLimituntil the next UTC day. - Failed credentials: wrong
secretCodeor linkpasswordvalues sent to the statistics, update and delete endpoints are counted per IP address. After 30 failures in 15 minutes these endpoints return429withrateLimit.tooManyAttempts, even for correct credentials, until the window passes. Requests made by the link owner or without credentials do not count.
Every 429 response carries a Retry-After header (seconds) and the same value in the retryAfter body field. Wait at least that long before retrying. The daily cap can be tightened by the operators during abuse waves.
Previews and successful reads are not rate-limited per request, but automated traffic that looks abusive may be slowed down or blocked.
Errors
bito.li uses conventional HTTP status codes: 2xx for success, 4xx when the request cannot be fulfilled as sent and 5xx for problems on our side. Every error body has the same shape:
error: a human-readable message in English. It may change; do not parse it.errorKey: a stable, machine-readable code. Branch on this.- Some errors add context fields, for example
reasonon expired links orblockedTypeon blocked links.
The table lists every errorKey the link endpoints can return.
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": "Custom code already taken.",
"errorKey": "customCodeErr.codeTaken"
}| Status | errorKey | Meaning |
|---|---|---|
| 400 | customCodeErr.endsWithSymbol | The custom code ends with . _ or - |
| 400 | customCodeErr.invalidLength | The custom code is not 5–64 characters of letters, digits, . _ - |
| 400 | customCodeErr.repeatedSymbols | The custom code contains two symbols in a row. |
| 400 | customCodeErr.startsWithSymbol | The custom code starts with . _ or - |
| 400 | validation.expirationInPast | On create: `expiresAt` is not in the future. On update: the new expiry is not later than the current one. |
| 400 | validation.expirationTooFar | expiresAt is more than 100 years away. |
| 400 | validation.invalidClickLimit | The click limit is not a whole number in the allowed range (or, on update, not above the current clicks). |
| 400 | validation.invalidControlMode | controlMode is not full, statsOnly or none. |
| 400 | validation.invalidExpiration | `expiresAt` is not a valid date, or `expireAfter` is not a whole number of at least 1. |
| 400 | validation.invalidExpireMode | expireMode is not one of the accepted values. |
| 400 | validation.invalidPasswordChars | The password contains spaces or unsupported characters. |
| 400 | validation.invalidPasswordLength | The password is empty or longer than 64 characters. |
| 400 | validation.invalidTitle | title is not a string. |
| 400 | validation.invalidUrl | The URL is not a valid http(s) address with a domain. |
| 400 | validation.originalUrlRequired | The destination URL is missing or empty. |
| 400 | validation.titleTooLong | title is longer than 200 characters. |
| 400 | validation.urlTooLong | The URL is longer than 2,048 characters. |
| 401 | auth.invalidPassword | The link password is missing or wrong. |
| 401 | auth.invalidSecret | The secret code is missing or wrong. |
| 401 | auth.noPermission | This link cannot be managed (not a full-mode link, or you are not the owner). |
| 401 | auth.passwordRequired | The link is password-protected; its destination is not shown. |
| 401 | auth.secretRequired | This link can only be managed by its owner. |
| 403 | customCodeErr.blockedKeyword | The custom code contains a blocked word (returned in blockedKeyword). |
| 403 | forbidden.noTracking | The link was created with controlMode none and has no statistics. |
| 403 | link.malicious | The link is blocked as harmful (see blockedType). Blocked links are kept as evidence. |
| 403 | validation.blacklistedDomain | The destination is on a blocklist or flagged as unsafe. |
| 403 | validation.selfShortenBlocked | The URL points to bito.li or lnk.ink itself. |
| 404 | notFound.link | No link exists with this short code. |
| 404 | notFound.route | The endpoint does not exist. Check the method and path. |
| 409 | customCodeErr.codeTaken | The custom code is in use or reserved. |
| 410 | expired.clicks | The link reached its click limit. |
| 410 | expired.deleted | The link was deleted. |
| 410 | expired.time | The link passed its expiry time. |
| 429 | rateLimit.dailyLimit | The daily link creation cap for this IP address was reached. |
| 429 | rateLimit.tooManyAttempts | Too many wrong secret codes or passwords from this IP address. Wait for `Retry-After` seconds. |
| 429 | rateLimit.tooManyRequests | More than 120 creation requests in 10 minutes. |
| 500 | server.createFailed | A unique short code could not be generated. Retry the request. |
| 500 | server.error | Unexpected server error. Retry later; contact us if it persists. |
/api/linksCreate a short link
Shortens a URL, with an optional custom code, password, expiry and control mode.
Creates a new short link and returns it with 201 Created. Links are checked against blocklists before they are saved and scanned by Google Safe Browsing right after, so a link can still be blocked shortly after it is created.
Reuse. Without customCode, if an identical active link already exists (same destination, control mode, password and owner, and a compatible expiry), that link is returned instead with 200 OK, and secretCode and password are null. If you need a link you can manage, use a customCode or a different destination.
Custom codes. If customCode is already taken by a link to the same destination, that link is returned with 200 OK; otherwise you get 409 customCodeErr.codeTaken. Codes that match bito.li pages (such as login or docs) are reserved.
Expiry. Use either a relative expiry (expireMode + expireAfter) or an absolute expiresAt. A time limit and a click limit can be combined: the link expires at whichever comes first. expiresAt takes precedence over a time-based expireMode; expireMode: "clicks" takes precedence over maxClicks.
Body parameters (JSON)
originalUrlstring RequiredThe destination URL.
https://is added when the scheme is missing.http or https only, needs a valid domain with a TLD, at most 2,048 characters after normalisation. Links to bito.li or lnk.ink are rejected.
customCodestring OptionalYour own short code instead of a random one.
5–64 characters: letters, digits,
._-. Cannot start or end with a symbol or contain two symbols in a row.controlModestring OptionalWho can see stats and manage the link. See Control modes.
One of:
fullstatsOnlynoneDefault:fullpasswordstring OptionalVisitors must enter this password before they are redirected.
1–64 characters: letters, digits and common symbols, no spaces. Stored and shown to the link manager as entered.
expireModestring OptionalRelative expiry unit. Use with
expireAfter.One of:
minuteshoursdaysclicksexpireAfterinteger OptionalNumber of
expireModeunits: minutes, hours or days from now, or the number of clicks.For
clicks: 1–100,000,000. For time units: a positive number, defaults to 1.Default:
1expiresAtstring (ISO 8601) OptionalAbsolute expiry time. Values that cannot be parsed are ignored.
At most 100 years in the future.
maxClicksinteger OptionalThe link stops redirecting after this many clicks.
Integer, 1–100,000,000.
Errors
- 400
validation.originalUrlRequiredThe destination URL is missing or empty. - 400
validation.invalidUrlThe URL is not a valid http(s) address with a domain. - 400
validation.urlTooLongThe URL is longer than 2,048 characters. - 400
customCodeErr.invalidLengthThe custom code is not 5–64 characters of letters, digits, . _ - - 400
customCodeErr.startsWithSymbolThe custom code starts with . _ or - - 400
customCodeErr.endsWithSymbolThe custom code ends with . _ or - - 400
customCodeErr.repeatedSymbolsThe custom code contains two symbols in a row. - 400
validation.invalidPasswordLengthThe password is empty or longer than 64 characters. - 400
validation.invalidPasswordCharsThe password contains spaces or unsupported characters. - 400
validation.invalidControlModecontrolMode is not full, statsOnly or none. - 400
validation.invalidClickLimitThe click limit is not a whole number in the allowed range (or, on update, not above the current clicks). - 400
validation.invalidExpiration`expiresAt` is not a valid date, or `expireAfter` is not a whole number of at least 1. - 400
validation.expirationInPastOn create: `expiresAt` is not in the future. On update: the new expiry is not later than the current one. - 400
validation.expirationTooFarexpiresAt is more than 100 years away. - 403
validation.selfShortenBlockedThe URL points to bito.li or lnk.ink itself. - 403
validation.blacklistedDomainThe destination is on a blocklist or flagged as unsafe. - 403
customCodeErr.blockedKeywordThe custom code contains a blocked word (returned in blockedKeyword). - 409
customCodeErr.codeTakenThe custom code is in use or reserved. - 429
rateLimit.tooManyRequestsMore than 120 creation requests in 10 minutes. - 429
rateLimit.dailyLimitThe daily link creation cap for this IP address was reached.
curl -X POST 'https://bito.li/api/links' \
-H 'Content-Type: application/json' \
-d '{
"originalUrl": "https://example.com/spring-sale?utm_source=newsletter",
"customCode": "spring-sale",
"expireMode": "clicks",
"expireAfter": 1000
}'{
"originalUrl": "https://example.com/spring-sale?utm_source=newsletter",
"shortUrl": "https://bito.li/spring-sale",
"shortCode": "spring-sale",
"statsUrl": "https://bito.li/d/spring-sale",
"clicks": 0,
"expiresAt": null,
"maxClicks": 1000,
"controlMode": "full",
"secretCode": "s-3f9-a1c-07b",
"password": null,
"userId": null
}201 for a new link. 200 when an existing link was reused (then secretCode and password are null). userId is set only for full-mode links created by a signed-in user.
/api/links/createCreate a link with a GET request
One-line shortening for spreadsheets, shell scripts and no-code tools.
A convenience version of Create a short link that fits in a single URL. Links created this way always use controlMode: "none": they have no secret code, no statistics and cannot be edited or deleted. Use POST /links if you need any of those.
URL-encode the url value (for example with encodeURIComponent or --data-urlencode), otherwise characters such as & or # in the destination are lost.
Validation, reuse, reserved codes and rate limits are the same as for POST /links.
Query parameters
urlstring RequiredThe destination URL, URL-encoded.
customstring OptionalOptional custom short code, same rules as
customCode.formatstring OptionalSet to
simpleto receive only the short URL as plain text instead of JSON.One of:
simple
Errors
- 400
validation.originalUrlRequiredThe destination URL is missing or empty. - 400
validation.invalidUrlThe URL is not a valid http(s) address with a domain. - 400
customCodeErr.invalidLengthThe custom code is not 5–64 characters of letters, digits, . _ - - 403
validation.blacklistedDomainThe destination is on a blocklist or flagged as unsafe. - 409
customCodeErr.codeTakenThe custom code is in use or reserved. - 429
rateLimit.tooManyRequestsMore than 120 creation requests in 10 minutes. - 429
rateLimit.dailyLimitThe daily link creation cap for this IP address was reached.
curl 'https://bito.li/api/links/create?url=https%3A%2F%2Fexample.com%2Fdocs%2Fgetting-started&format=simple'https://bito.li/k3Xb9With format=simple the body is the short URL as text/plain. Without it, the JSON body has the same shape as POST /links. Errors are always JSON, even with format=simple.
/api/links/{code}/statsRetrieve link statistics
Click totals, a 30-day timeline and breakdowns by country, city, device, browser, platform and referrer.
Returns the link’s settings and its click statistics. Who may read them depends on the link:
- full links with a secret code: pass
secret. The link password does not unlock statistics. - statsOnly links: public, unless the link has a password, then pass
password. - none links: statistics are not collected, the endpoint returns
403 forbidden.noTracking. - The owner signed in on bito.li can always read statistics of their own links.
Expired links return 410 unless you pass the correct secret code (or are the owner), so you can still read the final numbers.
Aggregated breakdowns are cached for up to 5 minutes and processed in the background, so a click can take a few minutes to appear in them. clicks and recentClicks (the last 50 clicks) update sooner. engagementsOverTime covers the last 30 days by UTC date. qrClicks counts visits through the link’s QR code.
Redirect-only lookup. With mode=redirect the endpoint returns just { "originalUrl": "…" }. It only checks the link password, which makes it useful to resolve a password-protected link to its destination.
Path parameters
codestring RequiredThe short code, for example
spring-saleforhttps://bito.li/spring-sale.
Query parameters
secretstring ConditionalSecret code of a full-mode link.
passwordstring ConditionalPassword of a password-protected link (statsOnly links and
mode=redirect).modestring OptionalSet to
redirectto return only the destination URL.One of:
redirect
Errors
- 401
auth.invalidSecretThe secret code is missing or wrong. - 401
auth.invalidPasswordThe link password is missing or wrong. - 401
auth.secretRequiredThis link can only be managed by its owner. - 403
forbidden.noTrackingThe link was created with controlMode none and has no statistics. - 404
notFound.linkNo link exists with this short code. - 410
expired.deletedThe link was deleted. - 410
expired.timeThe link passed its expiry time. - 410
expired.clicksThe link reached its click limit. - 429
rateLimit.tooManyAttemptsToo many wrong secret codes or passwords from this IP address. Wait for `Retry-After` seconds.
curl 'https://bito.li/api/links/spring-sale/stats?secret=s-3f9-a1c-07b'{
"duration": "18 ms",
"shortCode": "spring-sale",
"originalUrl": "https://example.com/spring-sale?utm_source=newsletter",
"title": null,
"shortUrl": "https://bito.li/spring-sale",
"clicks": 128,
"qrClicks": 12,
"createdAt": "2026-10-20T08:14:03.000Z",
"lastAccessed": "2026-10-20T08:14:03.000Z",
"expiresAt": null,
"maxClicks": 1000,
"controlMode": "full",
"hasPassword": false,
"isOwner": false,
"stats": {
"engagementsOverTime": [
{
"date": "2026-10-24",
"clicks": 41
},
{
"date": "2026-10-25",
"clicks": 87
}
],
"topPerformingDate": {
"date": "2026-10-25",
"clicks": 87
},
"topCountries": [
{
"countryCode": "VN",
"clicks": 90,
"percent": 70.3
}
],
"topPlatforms": [
{
"platform": "Android",
"clicks": 70,
"percent": 55
}
],
"topReferrers": [
{
"referer": "(direct)",
"clicks": 80,
"percent": 63
}
],
"deviceStats": [
{
"deviceType": "Mobile",
"clicks": 96,
"percent": 75
}
],
"browserStats": [
{
"browser": "Chrome",
"clicks": 81,
"percent": 63
}
],
"allCountries": [
{
"countryCode": "VN",
"clicks": 90,
"percent": 70.3
}
],
"allCities": [
{
"city": "Hanoi",
"countryCode": "VN",
"clicks": 52,
"percent": 40.6
}
],
"recentClicks": [
{
"timestamp": "2026-10-25T09:41:12.000Z",
"countryCode": "VN",
"city": "Hanoi",
"deviceType": "Mobile",
"browser": "Chrome",
"platform": "Android",
"referer": "(direct)"
}
]
},
"secretCode": "s-3f9-a1c-07b"
}The password itself is never returned; hasPassword tells whether the link has one. secretCode is returned only to the owner, to callers who sent the correct secret, and for full-mode links without a password. lastAccessed is the time the link was last created or updated, not the time of the last click; use recentClicks[0].timestamp for that.
/api/links/{code}/previewPreview a link
Look up where a short link goes without visiting it and without counting a click.
Returns the destination and basic settings of an active link. It needs no authentication and does not register a click, which makes it suitable for link checkers and unfurling.
The destination is not revealed for links that are password-protected (401), expired or deleted (410) or blocked as harmful (403 link.malicious, with blockedType). For blocked links, reveal=1 adds originalUrl to the error body so a warning page can show it on request. Never redirect users to it automatically.
Path parameters
codestring RequiredThe short code to look up.
Query parameters
revealstring OptionalSet to
1to include the destination of a blocked link in the403response.One of:
1
Errors
- 401
auth.passwordRequiredThe link is password-protected; its destination is not shown. - 403
link.maliciousThe link is blocked as harmful (see blockedType). Blocked links are kept as evidence. - 404
notFound.linkNo link exists with this short code. - 410
expired.deletedThe link was deleted. - 410
expired.timeThe link passed its expiry time. - 410
expired.clicksThe link reached its click limit.
curl 'https://bito.li/api/links/spring-sale/preview'{
"shortCode": "spring-sale",
"originalUrl": "https://example.com/spring-sale?utm_source=newsletter",
"createdAt": "2026-10-20T08:14:03.000Z",
"expiresAt": null,
"maxClicks": 1000,
"controlMode": "full",
"passwordProtected": false,
"blockedType": null
}blockedType is null for links that are not blocked. Expired responses include expired: true and reason (time or clicks).
/api/links/{code}Update a link
Change the destination, title, password or expiry of a full-mode link.
Only fields you send are changed; everything else is kept. An empty body succeeds without changes. Only full links can be updated: authorise with the link’s secretCode, or as the signed-in owner. Changes take effect for visitors immediately.
- A new
originalUrlgoes through the same checks as creation, plus a synchronous Google Safe Browsing check. Unsafe destinations are rejected with403 validation.blacklistedDomain. password: nullor""removes the password.expireMode: "never"removes both the time and the click limit.- A time expiry is counted from now and must end later than the current expiry (
400 validation.expirationInPastotherwise). Setting one removes the click limit. - A click limit must be greater than the clicks the link already has. Setting one removes the time limit.
Expired links can be updated, which is how you extend or revive them. Blocked links cannot be changed.
Path parameters
codestring RequiredThe short code of the link to update.
Body parameters (JSON)
secretCodestring ConditionalSecret code of the link. Not needed when you call as the signed-in owner.
originalUrlstring OptionalNew destination URL. Same rules as on creation.
titlestring OptionalA label for the link, shown in the dashboard. Trimmed.
At most 200 characters.
passwordstring | null OptionalNew password, or
null/""to remove it.1–64 characters, no spaces.
expireModestring OptionalNew expiry:
never, a time unit, orclicks.One of:
neverminuteshoursdaysclicksexpireAfterinteger OptionalNumber of units from now, or the click limit when
expireModeisclicks.Time units: an integer ≥ 1. Clicks: 1–100,000,000 and above the current click count.
Errors
- 400
validation.invalidUrlThe URL is not a valid http(s) address with a domain. - 400
validation.invalidTitletitle is not a string. - 400
validation.titleTooLongtitle is longer than 200 characters. - 400
validation.invalidPasswordLengthThe password is empty or longer than 64 characters. - 400
validation.invalidPasswordCharsThe password contains spaces or unsupported characters. - 400
validation.invalidExpireModeexpireMode is not one of the accepted values. - 400
validation.invalidExpiration`expiresAt` is not a valid date, or `expireAfter` is not a whole number of at least 1. - 400
validation.expirationInPastOn create: `expiresAt` is not in the future. On update: the new expiry is not later than the current one. - 400
validation.invalidClickLimitThe click limit is not a whole number in the allowed range (or, on update, not above the current clicks). - 401
auth.invalidSecretThe secret code is missing or wrong. - 401
auth.noPermissionThis link cannot be managed (not a full-mode link, or you are not the owner). - 403
validation.blacklistedDomainThe destination is on a blocklist or flagged as unsafe. - 403
link.maliciousThe link is blocked as harmful (see blockedType). Blocked links are kept as evidence. - 404
notFound.linkNo link exists with this short code. - 410
expired.deletedThe link was deleted. - 429
rateLimit.tooManyAttemptsToo many wrong secret codes or passwords from this IP address. Wait for `Retry-After` seconds.
curl -X PATCH 'https://bito.li/api/links/spring-sale' \
-H 'Content-Type: application/json' \
-d '{
"secretCode": "s-3f9-a1c-07b",
"originalUrl": "https://example.com/autumn-sale",
"expireMode": "days",
"expireAfter": 30
}'{
"success": true
}/api/links/{code}/deleteDelete a link
Stop a full-mode link from redirecting.
Deletes the link. Visitors see a “link not found” page instead of being redirected, and statistics are no longer available. The short code stays reserved and cannot be reused.
Authorise with the link’s secretCode, or as the signed-in owner. Links owned by an account can be restored from the dashboard’s Trash within 30 days; anonymous links cannot be restored.
This endpoint uses POST (not DELETE) so it works with clients and proxies that cannot send a body with DELETE.
Path parameters
codestring RequiredThe short code of the link to delete.
Body parameters (JSON)
secretCodestring ConditionalSecret code of the link. Not needed when you call as the signed-in owner.
Errors
- 401
auth.invalidSecretThe secret code is missing or wrong. - 401
auth.noPermissionThis link cannot be managed (not a full-mode link, or you are not the owner). - 403
link.maliciousThe link is blocked as harmful (see blockedType). Blocked links are kept as evidence. - 404
notFound.linkNo link exists with this short code. - 410
expired.deletedThe link was deleted. - 429
rateLimit.tooManyAttemptsToo many wrong secret codes or passwords from this IP address. Wait for `Retry-After` seconds.
curl -X POST 'https://bito.li/api/links/spring-sale/delete' \
-H 'Content-Type: application/json' \
-d '{
"secretCode": "s-3f9-a1c-07b"
}'{
"success": true
}/api/links/stats/totalTotal links
The total number of links ever created on bito.li and lnk.ink.
A public counter, cached for 60 seconds. Useful for status pages and widgets.
This endpoint takes no parameters.
Errors
This endpoint does not return endpoint-specific errors.
curl 'https://bito.li/api/links/stats/total'{
"total": 685214
}Need help?
Found something unclear or a behaviour that doesn’t match these docs? Write to [email protected] and include the request you sent and the errorKey you received.