REST API
Interactive docs
Every Inkpage Links instance publishes API docs at:
https://your-domain/_docs/openapi.json— machine-readable OpenAPIhttps://your-domain/_docs/scalar— friendly UIhttps://your-domain/_docs/swagger— classic Swagger UI
The OpenAPI document is generated from the same schemas the server validates against, so it always matches the running deployment. For the public instance:
New here? Start with the API quickstart.
Authentication
Send your credential in the Authorization header:
Authorization: Bearer YOUR_TOKENTwo kinds of credential work:
| Credential | Who it is | Scope |
|---|---|---|
NUXT_SITE_TOKEN | The instance owner (break-glass) | Full access, including instance configuration |
API key (ink_…) | A named integration | Only the role the key was granted |
Creating an API key
An API key is created once and its token is shown a single time:
POST /api/api-keys/create
Authorization: Bearer YOUR_SITE_TOKEN
Content-Type: application/json
{ "name": "n8n", "role": "power-user" }{ "key": { "id": "…", "prefix": "ink_ab12cd34ef56", "role": "power-user" }, "token": "ink_…" }Store the token immediately — it cannot be recovered, and only its SHA-256 hash is kept.
| Operation | Route | Requires |
|---|---|---|
| List keys | GET /api/api-keys | instance:config |
| Create a key | POST /api/api-keys/create | instance:config |
| Revoke a key | POST /api/api-keys/revoke | instance:config |
Keys accept expiresAt (Unix seconds) and can never hold the superadmin role. Listing never returns tokens or hashes.
You can also create and revoke keys in Dashboard → API keys, without curl.
Key revocation is not instant
The auth middleware caches a key for up to 60 seconds per Cloudflare location, so a revoked token may keep working for a few seconds.
Rate limits
Each API key is rate limited (default 120 requests per minute, configured with NUXT_API_KEY_RATE_LIMIT); exceeding it returns 429 with Retry-After. The site token is not throttled.
Sessions and Access
API-key and site-token requests cannot be combined with browser sessions: with Cloudflare Access enabled, browsers can additionally authenticate with a verified Access login. The MCP endpoint accepts both credential types.
CORS
Optional. Set NUXT_API_CORS=true at build time to allow browser apps on other sites to call /api/**. Login is still required. See configuration.
Before you call link APIs
Storage must be ready
Until you open Dashboard → Links once after deploy, most /api/link/** calls fail with “storage not ready” (HTTP 423). See storage setup.
upsertcreates when free; if the short code exists, returns it withstatus: "existing"(does not overwrite)searchmatches short code, URL, comment, and tagscheckprobes target URLs from the serververifychecks how you are authenticatedlocationreturns approximate coordinates when Cloudflare provides them- Image upload:
multipart/form-dataupload requiring bothfileand target linkslug; requires R2 (JPEG/PNG/WebP/GIF, max 5 MB)
Endpoint groups
Use the OpenAPI UI for full request/response details.
| Group | Routes |
|---|---|
| Links | /api/link/create, edit, upsert, delete, query, search, list, check, tags |
| Subdomains | /api/domains, /api/domains/create, /api/domains/update, /api/domains/delete — Subdomains |
| API keys | /api/api-keys, /api/api-keys/create, /api/api-keys/revoke |
| Import/export | /api/link/import, /api/link/export — Import and Export |
| Storage setup | /api/link/migration/status, /api/link/migration/run — storage setup |
| AI | /api/link/ai, /api/link/og-ai — Workers AI |
| Analytics | /api/stats/**, /api/logs/** — Analytics |
| Utilities | /api/verify, /api/location, /api/upload/image, /api/backup |
| MCP | /api/mcp — MCP Server |
Everything the dashboard can do
A link carries its full configuration in one payload — nothing is dashboard-only:
{
"url": "https://example.com/target",
"slug": "promo",
"comment": "internal note",
"password": "visitor-password",
"cloaking": true,
"unsafe": true,
"redirectWithQuery": true,
"expiration": 1798761600,
"title": "Preview title",
"description": "Preview description",
"image": "https://cdn.example.com/cover.png",
"apple": "https://apps.apple.com/app/id000000",
"google": "https://play.google.com/store/apps/details?id=com.example",
"proxy": false,
"tags": ["campaign", "q4"],
"geo": { "BR": "https://example.com/brasil", "US": "https://example.com/us" }
}cloakingkeeps the short link in the address bar instead of revealing the destinationpasswordis hashed on the server; reads return a mask, and an empty string clears it on editgeoroutes visitors by two-letter country codeimageis stored as given and used directly in the preview tag: either a publichttp(s)URL or the key returned by/api/upload/image. Internal paths are capped at 128 characters; absolutehttp(s)URLs are capped at the standard URL length limit
Pagination
List endpoints return an opaque cursor instead of an offset. Pass the returned cursor to fetch the next page, and stop when list_complete is true. Each list keeps its own array key:
| Route | Array key | Notes |
|---|---|---|
/api/link/list | links | Also accepts sort, tag, and status |
/api/domains | domains | Also accepts q and status |
/api/api-keys | keys | limit maxes at 100, default 50 |
{
"links": [],
"cursor": "eyJzbHVnIjoicHJvbW8ifQ==",
"list_complete": false
}When list_complete is true, cursor is omitted. Never build a cursor yourself — treat it as opaque. The limit parameter caps page size per route; the OpenAPI document lists each route's maximum.
Errors
Errors use standard HTTP status codes. The body carries h3's statusCode and statusMessage, plus data when there is more to say — a validation failure lists the offending fields:
{ "statusCode": 400, "statusMessage": "Bad Request", "data": { "issues": [] } }| Status | Meaning |
|---|---|
400 | Request body or query failed validation; the body names the invalid fields |
401 | Missing, malformed, expired, or revoked credential |
403 | Credential is valid but the role lacks the required permission |
404 | The link, subdomain, or domain does not exist |
409 | Conflict — slug taken, subdomain exists, link replaced, or slug unallocatable |
413 | /api/upload/image only: the file exceeds 5 MB |
423 | Storage is not initialized, or a D1 migration is pending |
429 | API-key rate limit exceeded; retry after Retry-After seconds |
A 401 never reveals whether a token existed, so treat it as "credential not usable" and create a new key rather than probing for one.
Recipes
All examples use https://your-domain as the base URL and read the token from the environment.
Create a protected link
curl -X POST https://your-domain/api/link/create \
-H "Authorization: Bearer $INKPAGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/secret-report",
"slug": "report",
"password": "visitor-password",
"cloaking": true
}'Create a link and its subdomain in one request
/api/domains/create takes the label in name, and accepts url instead of slug. It creates both records atomically:
curl -X POST https://your-domain/api/domains/create \
-H "Authorization: Bearer $INKPAGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "promo", "url": "https://example.com/landing" }'{ "domain": { "name": "promo", "slug": "k3f9xq", "status": "active" } }Pass slug instead of url to attach the subdomain to a link that already exists. Sending both, or neither, is a validation error.
Issue a key for another integration
Creating a key requires the owner credential, not another API key:
export INKPAGE_SITE_TOKEN="your_instance_site_token"
curl -X POST https://your-domain/api/api-keys/create \
-H "Authorization: Bearer $INKPAGE_SITE_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "zapier", "role": "power-user", "expiresAt": 1798761600 }'Import links in bulk
The payload needs a version and a links array, matching the export format:
curl -X POST https://your-domain/api/link/import \
-H "Authorization: Bearer $INKPAGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"version": "1.0",
"links": [ { "url": "https://example.com/a", "slug": "a" } ]
}'Call the API from an AI client
The MCP endpoint speaks the Model Context Protocol over HTTP and accepts the same bearer token:
claude mcp add --transport http inkpage https://your-domain/api/mcp \
--header "Authorization: Bearer $INKPAGE_TOKEN"See Integrations for the available tools.
Versioning
The API follows the deployment, not a version number: /api/link/** and /api/domains/** keep their field names and semantics across releases, and new optional fields may appear. Read /_docs/openapi.json from the instance you are integrating with — it is the authoritative contract for that deployment, and it is regenerated on every build.