Getting Started
Read any public link-in-bio page as structured JSON, and follow its links to where they really go. No account on Linktree or anywhere else — just your FetchLayer key.
1. Get your API key
Create an account and generate a FetchLayer API key from your dashboard. The same key works on every platform.
Create free account →2. Read a page
Pass the full page address. Not a handle — spotify exists on a dozen services.
curl -X POST https://api.fetchlayer.dev/link-in-bio/profile \
-H "Authorization: Bearer ss-your-key" \
-H "Content-Type: application/json" \
-d '{"profile":"https://linktr.ee/spotify"}'The links are at profile.links[], not at the root
/profile the list is profile.links[], the count is profile.linkCount and the creator’s name is profile.identity.displayName. Only notes[] and pagesFetched sit at the top level. The /links route below is the one that puts links at the root. A bare handle is a 400, on purpose
{"profile":"nike"} returns “Handles are not unique across services, so "nike" is ambiguous. Pass the full profile URL, or name the provider.” If a handle is genuinely all you have, add "provider":"linktree". 3. Or just the links
Same page, narrower answer — and on this route links is at the top level. totalLinks reports how many the page had before kind and limit narrowed it, so a filter that matches nothing is visibly a filter.
curl -X POST https://api.fetchlayer.dev/link-in-bio/links \
-H "Authorization: Bearer ss-your-key" \
-H "Content-Type: application/json" \
-d '{"profile":"https://linktr.ee/spotify","kind":"link","limit":100}'4. Follow the links to where they really go
Set resolveDestinations and every link gains a destination: the final url, its host, the hop chain, the page title, and shortened when a shortener or affiliate redirector was involved. That is how you find the retailer behind a campaign link rather than the wrapper in front of it.
curl -X POST https://api.fetchlayer.dev/link-in-bio/links \
-H "Authorization: Bearer ss-your-key" \
-H "Content-Type: application/json" \
-d '{"profile":"https://linktr.ee/spotify","resolveDestinations":true}'
curl -X POST https://api.fetchlayer.dev/link-in-bio/unwrap-link \
-H "Authorization: Bearer ss-your-key" \
-H "Content-Type: application/json" \
-d '{"url":"https://bennyblanco.lnk.to/TeOlvido"}'Read destinationsResolved, not a null destination
destination means “not asked for” when destinationsResolved is false, and “could not be reached” when it is true — in which case unresolvedReason says why. Resolving is off by default because it turns one page fetch into one request per link: a 68-link page is a very different request from a 3-link one. 5. Triage a list of addresses first
Four services are readable here — Linktree, Lnk.Bio, AllMyLinks and solo.to — and twenty-six more are recognised with the reason they are not. Find out before you spend a page fetch.
curl -X POST https://api.fetchlayer.dev/link-in-bio/resolve-url \
-H "Authorization: Bearer ss-your-key" \
-H "Content-Type: application/json" \
-d '{"url":"https://bento.me/someone"}'
curl -X POST https://api.fetchlayer.dev/link-in-bio/detect-provider \
-H "Authorization: Bearer ss-your-key" \
-H "Content-Type: application/json" \
-d '{"url":"https://bento.me/someone"}'Knowing a service is dead is worth as much as reading a live one
/detect-provider answers a bento.me address with “Bento has closed and its addresses now redirect to Linktree; use the creator’s Linktree page instead”. Beacons, HeyLink, Direct.me, Lynk.id, Komi, Pillar and Bio.fm come back as not available in this release (“Beacons pages can't be read”); Carrd as a general site builder with no link list at all. /resolve-url gives the same supported verdict for free, fetching nothing. Empty pages, and broken ones
A page that is live but publishes nothing is a 200 with linkCount: 0 and the note “This page is live and published no links.” — a parked handle looks exactly like that. A page whose link list could not be read at all is a 502. The two are deliberately different statuses, so an empty list here genuinely means empty and you can alert on the 502 alone.
Coverage really is uneven between pages, and that is creators rather than the reader: on 16 September 2026, linktr.ee/selenagomez returned 68 links, shakira 11, spotify and adidas 6 each, nike 3, and bbc 0. All six were a 200.
Errors and retries
A 400 names the field to fix, and is also what a recognised-but-unreadable service returns. A 404 means no page exists at that address. A 502 means the page was fetched but could not be read — worth retrying once, and worth reporting if it persists. A 503 means the service was temporarily unavailable; wait before retrying.
Error responses
Every error, on every route, is JSON in one shape: error (what went wrong, in words), status, code, retryable and requestId, which also comes as the X-Request-Id header. Branch on code and retryable rather than on the text; on a 5xx the text is generic. Quote requestId when reporting a problem.
{
"error": "Missing required parameter: url",
"status": 400,
"missing": ["url"],
"code": "invalid_input",
"retryable": false,
"requestId": "922874e3-c0d0-40d8-8866-d198af132420"
}| code | Status | Retryable | Meaning |
|---|---|---|---|
| invalid_input | 400 | no | The request needs fixing; error names the field. A missing required parameter also lists it in missing. |
| unauthorized | 401 | no | The API key is missing or not valid. |
| insufficient_credits | 402 | no | Nothing left to spend: no credits, a used-up free trial, or a monthly allowance and credit balance both spent. error says which. Waiting does not help; topping up does. |
| forbidden | 403 | no | This key or account is not allowed this request. |
| not_found | 404 | no | What the request names does not exist or is not public. |
| expired | 410 | no | What the request refers to is no longer available; ask for it afresh. |
| quota_exceeded | 429 | no | A request-rate limit was reached; error says which. Slow down before sending more. |
| cancelled | 499 | no | The caller closed or cancelled the request before it finished. The work was stopped and nothing was charged. |
| time_limit | 503 | yes | Your timeoutMs ran out before there was anything to return; ask again, or allow more time. |
| temporarily_unavailable | 503 | yes | Not available right now; ask again shortly. |
| posts_unavailable | 403 | no | TikTok user videos: TikTok is not showing this account’s posts publicly right now. Asking again at once returns the same answer; nothing is charged. |
| store_unreachable | 503 | yes | Store routes (Shopify, WooCommerce): the store itself can’t be reached right now — it may be offline. Ask again later. Where a store route doesn’t say so yet, an offline store is temporarily_unavailable. |
| upstream_error | 502 | yes | The source answered in a way that could not be used; ask again later. |
| internal_error | 500 | yes | Something failed on our side; ask again later, and quote requestId if it persists. |
| tool_error | — | no | MCP only: a tool call failed in a way no other code describes; error says what. |
More codes may be added; treat one you do not recognise by its retryable flag. Over MCP, a tool error carries the same fields in structuredContent, and as a JSON text block beside the message.