Short links and secrets, from code.
One bearer key, plain JSON, and the same plan rules as the dashboard. Built for extensions, CLIs, MCP servers and internal tools.
Early access. The v1 endpoints are being switched on workspace by workspace. If a call returns 503 with code disabled, the API is not enabled for you yet. Keys can be created today and will work as soon as it is.
1. Authentication
Create a key at /app/api-keys (workspace owners and admins). The raw key is shown once; LinkPilot keeps only its SHA-256 hash. Send it on every request:
Authorization: Bearer lp_live_...
Keys are tied to a workspace, so there is no tenant parameter anywhere. Revoked, expired, unknown and malformed keys all return the same 401 unauthorized. Keys are never written to logs.
Base URL: https://khiydxamyihofmtmgvjz.supabase.co/functions/v1/api-v1
2. Routes
| Method | Path | What it does |
|---|---|---|
| GET | /me | Workspace, plan, limits, usage and your key's rate budget |
| POST | /links | Create a short link (url, optional slug, domain, title, tags) |
| GET | /links | List links, newest first, cursor paginated |
| GET | /links/{id} | Fetch one link |
| DELETE | /links/{id} | Delete a link |
| POST | /secrets | Create a secret link (ciphertext, enc_version, ttl_seconds, burn_after_read, passphrase_hash on Pro) |
| GET | /secrets | List secret metadata (never payloads) |
| DELETE | /secrets/{id} | Revoke a secret and destroy its payload |
| POST | /public/links | Create an anonymous link with a client_id, no key |
| POST | /public/secrets | Create an anonymous burn-after-read secret, no key |
Lists return { data, next_cursor }; pass next_cursor back as cursor to continue. limit is 1 to 100.
3. Examples
Create a link with curl
curl -X POST "https://khiydxamyihofmtmgvjz.supabase.co/functions/v1/api-v1/links" \
-H "Authorization: Bearer $LINKPILOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/launch","slug":"launch","tags":["campaign"]}'
Create a secret from JavaScript
const b64u = (b) => btoa(String.fromCharCode(...new Uint8Array(b)))
.replace(/+/g, "-").replace(///g, "_").replace(/=+$/, "");
// 1. Encrypt locally. This key is generated here and never sent anywhere.
const key = crypto.getRandomValues(new Uint8Array(32));
const iv = crypto.getRandomValues(new Uint8Array(12));
const k = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["encrypt"]);
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, tagLength: 128 },
k,
new TextEncoder().encode("staging db password"),
);
const ciphertext = `v1.${b64u(iv)}.${b64u(ct)}`;
// 2. Store the blob. LinkPilot cannot read it.
const res = await fetch("https://khiydxamyihofmtmgvjz.supabase.co/functions/v1/api-v1/secrets", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LINKPILOT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ciphertext, enc_version: 1, ttl_seconds: 3600 }),
});
if (!res.ok) throw new Error((await res.json()).error.code);
const { secret_url } = await res.json();
// 3. Attach the key. Without this the link can never be opened, by anyone.
const shareUrl = `${secret_url}#k=${b64u(key)}`;
List links from Python
import os, requests
r = requests.get(
"https://khiydxamyihofmtmgvjz.supabase.co/functions/v1/api-v1/links",
params={"limit": 20},
headers={"Authorization": f"Bearer {os.environ['LINKPILOT_API_KEY']}"},
)
r.raise_for_status()
for link in r.json()["data"]:
print(link["short_url"], "->", link["url"])
print("remaining this hour:", r.headers["X-RateLimit-Remaining"])
Create an anonymous link with no key
curl -X POST "https://khiydxamyihofmtmgvjz.supabase.co/functions/v1/api-v1/public/links" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com","client_id":"my-cli-0123456789abcdef"}'
A Postman collection and an OpenAPI 3.1 document live next to the contract in the repository under docs/api/.
4. How secrets are stored
We want to be precise, because an API client has real work to do here: the API will not accept a plaintext payload at all.
- You encrypt, we store a blob. Send
ciphertext(AES-GCM-256,v1.<iv>.<ct>) withenc_version: 1. Sendingpayloadis rejected rather than quietly accepted. - You must append the key yourself. The
secret_urlwe return has no key in it and cannot: we have never seen one. Add#k=<base64url key>. Browsers never transmit the part after the#, so the recipient decrypts locally. - There is no recovery. Lose the key and the secret is gone, for you and for us. That is the guarantee working.
- Destroyed on burn, expiry or revoke. The stored ciphertext is overwritten server-side.
- Never returned by the API. No route echoes a payload or a ciphertext. The only reader is the recipient's reveal page, which records the view.
- Passphrases (Pro and above) are a real second factor: stretched with PBKDF2-SHA256 and folded into the key with HKDF, so neither the link alone nor the passphrase alone decrypts. Send only
passphrase_hash, the SHA-256 hex \u2014 never the raw passphrase, which is half the key. - What it does not cover. Anyone holding the full link holds the key, so short expiry and burn-after-read still matter. File attachments are encrypted the same way, with a key derived from the same link.
The wire format is specified in the API reference, and @uselinkpilot/sdk does the whole exchange in one call if you would rather not hand-roll it. The same model is described on the security architecture page.
5. Plan limits and rate limits
| Limit | Free | Pro | Agency |
|---|---|---|---|
| Links | 50 | Unlimited | Unlimited |
| Active secrets | 5 | 100 | 100 |
| Passphrase secrets | No | Yes | Yes |
| Custom domains for links | Existing verified domains | Up to 5 | Unlimited |
| Requests per key per hour | 60 | 1,000 | 5,000 |
The API applies the same plan rules as the dashboard, read from the same place, so an agency roll-up applies here too. GET /me returns the live numbers for your workspace. Every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
6. Public routes without a key
POST /public/links and POST /public/secrets exist for clients that cannot show a browser challenge: CLIs, MCP servers, the launcher. They take an opaque client_id (16 to 64 characters, generated once per install) and create records in the same anonymous pool as the free web tools.
- 20 creations per client per day and 60 per network per hour, plus the pool's own per-network caps.
- Same Google Safe Browsing and threat-feed screening as every other creation path.
- No custom domain, no custom slug, no passphrase; public secrets always burn after one read and expire within 24 hours.
- The response includes a
claim_tokenso a signed-in user can claim the record into a workspace later. Treat it as a credential.
7. Errors
Every error is { "error": { "code", "message", "upgrade_url"? } }. Messages name the field, never your data. Resources in other workspaces are a 404, not a 403.
| Code | HTTP | When |
|---|---|---|
| unauthorized | 401 | Missing, malformed, unknown, revoked or expired key |
| disabled | 503 | The API is not enabled for this deployment yet |
| invalid_request | 400 | Validation failure, blocked destination, taken slug, unknown domain |
| not_found | 404 | No such route, or the resource is not in your workspace |
| plan_limit | 402 | Link or active-secret quota reached (upgrade_url included) |
| pro_required | 402 | Passphrase on a plan without it (upgrade_url included) |
| rate_limited | 429 | Hourly key budget or public allowance used up (Retry-After included) |
Questions developers ask
- Which plans include the API?
- API keys are created from the dashboard at /app/api-keys. Each key uses its workspace's plan limits: Free keeps 50 links and 5 active secrets with 60 requests per hour; Pro has unlimited links, 100 active secrets, passphrase-protected secrets and 1,000 requests per hour; Agency plans get 5,000 requests per hour.
- Are secret payloads end-to-end encrypted?
- Yes, and the API will not accept plaintext at all. You encrypt with AES-GCM-256 and send ciphertext; LinkPilot stores a blob it has no key for and cannot decrypt. Because we never see your key, the secret_url we return has no key in it — you append it yourself as a #k= fragment, which browsers never transmit. Lose the key and the secret is unrecoverable by anyone, including us. File attachments are encrypted the same way, with a key derived from the same link.
- Can I create links without an API key?
- Yes. POST /public/links and POST /public/secrets take an opaque client_id instead of a key and create records in the same anonymous pool as the free web tools: 20 creations per client per day, 60 per network per hour, no custom domain, no passphrase, secrets expire within 24 hours.
- What happens when I hit a limit?
- Plan limits return HTTP 402 with a plan_limit or pro_required code and an upgrade_url. Rate limits return 429 with a Retry-After header. Every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset so clients can back off before that happens.
- Is the API stable?
- v1 routes will not change in a breaking way. Breaking changes ship as a new version. During early access the endpoints are switched on workspace by workspace; a 503 response with code disabled means the API is not enabled for your workspace yet.
Ready to build?
Create a key in your workspace, or start with the free plan.