Links
REST API
Create, read, update and delete permanent and temporary links over the REST API, and resolve any link without an API key.
A link is a short URL on your account’s host, https://<username>.dectog.com/<slug>. Permanent links live until you delete them or give them an expiry, and carry routing, social-preview and tracking settings. Temporary links expire after a TTL and carry only a target and a social preview. Both kinds can be paused, archived, deleted and restored (Pause, expire, archive and delete).
Every route on this page except resolve needs the Bearer key and the request signature described in API overview. All routes are scoped to the account that owns the key: you can only see and change your own links.
The Link object
Section titled “The Link object”Every permanent-link route returns the full object, including fields you never set (they come back as null).
{ "id": "6f1c2a0e-3b7d-4c8e-9a11-2f5d8c0b4e77", "slug": "spring-sale", "target": "https://shop.example.com/sale", "url": "https://acme.dectog.com/spring-sale", "app_id": "0d4e5f66-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "click_count": 0, "created_at": "2026-10-12T09:00:00.000Z", "updated_at": null, "metadata": { "screen": "sale", "campaign": "spring" }, "og_title": null, "og_description": null, "og_image_url": null, "ios_deep_link_path": "sale", "android_deep_link_path": "sale", "redirect_url": null, "fallback_url": null, "label": null, "campaign": "spring", "adgroup": null, "creative": null, "ios_tap_to_store": null, "mobile_interstitial": null, "append_click_id": null, "routing_rules": null, "template_id": null, "utm_source": null, "utm_medium": null, "utm_campaign": null, "utm_term": null, "utm_content": null, "external_id": null, "referrer_customer_id": null, "expires_at": null, "expired_url": null, "disabled_at": null, "disabled_reason": null, "archived_at": null, "deleted_at": null}| Field | Type | Meaning |
|---|---|---|
id |
string | UUID, assigned by the server. |
slug |
string | The path segment of the link. |
target |
string or null | The web destination. null means “inherit the app’s fallback URL at tap time”. |
url |
string | The public link URL in this deployment’s URL mode (subdomain on the hosted service, <PUBLIC_BASE_URL>/r/<username>/<slug> in path mode). |
app_id |
string or null | The app whose store URLs, schemes and fallback apply to this link. null means the account’s default app (its oldest app). |
click_count |
number | Lifetime counter. It grows on web taps, on SDK-reported clicks and on clicks synthesized from a store referrer. It never goes down. |
created_at, updated_at |
string or null | ISO 8601 UTC. updated_at stays null until the first PUT. |
metadata |
JSON or null | Your own data. Delivered to the app on attributed installs as custom_params and inside link.metadata. |
og_title, og_description, og_image_url |
string or null | Social preview overrides for bots and unfurlers. Fall back to the app’s values. See Social previews and QR. |
ios_deep_link_path, android_deep_link_path |
string or null | The app route a custom-scheme open uses on that platform: scheme://<path>?dtg_l=<username>/<slug> instead of scheme://<username>/<slug>?dtg_l=…. Stored without a leading slash. |
redirect_url |
string or null | Mobile visitors without the app go here instead of the store. A store URL here is still tagged with the click id. |
fallback_url |
string or null | Where desktop visitors go instead of the QR page, and the last mobile fallback. |
label, campaign, adgroup, creative |
string or null | Tracking defaults copied onto every click of this link. A dec_<field> query parameter on the tapped URL wins for that tap. See URL parameters. |
ios_tap_to_store, mobile_interstitial |
boolean or null | This link’s iOS tap-to-store page and mobile interstitial. null follows the app’s setting. See How a tap is routed. |
append_click_id |
boolean or null | Whether the link adds dtg_click to its web destinations, for dectog.js. null is off, or the template’s value. |
routing_rules |
array or null | Geo and language rules, first match wins. See Geo and language rules. |
template_id |
string or null | The template whose values fill every field this link leaves empty. The fields above are the link’s own. |
utm_source, utm_medium, utm_campaign, utm_term, utm_content |
string or null | Added to the link’s web destinations and stored on clicks whose URL has none. See Link-level UTM values. |
external_id |
string or null | Your own id for the link (a product or CMS row id), unique in the account. See Upsert and external ids. |
referrer_customer_id |
string or null | Set on a customer’s referral link. See Referral programs. |
expires_at, expired_url |
string or null | When the link expires, and where taps go after that instead of a 410 page. |
disabled_at, disabled_reason |
string or null | Paused since, and your note on why. A paused link answers 410 and records nothing. |
archived_at |
string or null | Filed away in the dashboard. The link keeps working. |
deleted_at |
string or null | Deleted. Only listed with include_deleted. |
Slug rules
Section titled “Slug rules”- Allowed characters:
A-Z,a-z,0-9,_and-. Length 1 to 128. - Reserved:
healthz,readyz,bulkandupsert(any case), and any slug starting withext_, which names a link by its external id. - The slug is used exactly as sent. Leading or trailing whitespace makes it invalid. Slugs are case-sensitive:
Saleandsaleare two different links. - One namespace per account for permanent and temporary links. Creating either kind with a slug the other kind holds returns
409 {"error": {"code": "conflict", "message": "Slug already exists", "slug": "<slug>"}}. - A slug becomes free again only when the link is deleted permanently. The daily job’s delete of an expired temporary link is a soft delete, so it keeps its slug. A deleted link keeps its slug, because its history is filed under it: creating that slug answers
409 {"error": {"code": "conflict", "message": "Slug belongs to a deleted link: restore it, or delete it permanently to reuse the slug", "slug": "<slug>", "deleted": true}}.
Create a link
Section titled “Create a link”POST /api/v1/links returns 201 with the Link.
| Field | Type | Required | Meaning |
|---|---|---|---|
slug |
string | yes | See the slug rules above. |
target |
string or null | no | An http(s) URL. null, "" or absent stores null (inherit the app’s fallback URL). |
app_id |
string | no | The id of one of your apps. Default: your oldest app, or null when the account has no app yet. |
metadata |
JSON object | no | Your own key-value data. null or "" stores null; any other non-object value (a string, number, boolean or array) answers 400 {"error": {"code": "invalid_request", "message": "metadata must be a JSON object"}}. |
og_title, og_description |
string | no | Trimmed; at most 150 characters each. "" stores null. |
og_image_url |
string | no | An http(s) URL. "" stores null. |
ios_deep_link_path, android_deep_link_path |
string | no | A relative route such as products/42?color=black. At most 512 characters; no :, whitespace, #, ; or control characters. A leading / is stripped. "" stores null. |
redirect_url, fallback_url |
string | no | http(s) URLs. "" stores null. |
label, campaign, adgroup, creative |
string | no | Trimmed; at most 255 characters; no control characters. "" stores null. |
ios_tap_to_store, mobile_interstitial |
boolean or null | no | true or false overrides the app’s setting for this link; null or absent follows it. Anything else answers 400 Invalid ios_tap_to_store: must be true, false or null (follow the app). |
append_click_id |
boolean or null | no | true adds dtg_click=<click id> to the link’s web destinations (never stores, MMP links or schemes); false or null does not. A template can set it. Anything else answers 400 Invalid append_click_id: must be true, false or null. |
routing_rules |
array or null | no | Up to 20 rules; null or [] stores none. See below. |
template_id |
string | no | One of your templates; anything else answers 400 Unknown template_id. When app_id is absent, the template’s app applies. |
utm_source … utm_content |
string | no | Trimmed; at most 255 characters; no control characters. "" stores null. |
external_id |
string | no | 1 to 255 characters, no control characters. Taken by another link: 409 {"error": {"code": "conflict", "message": "external_id belongs to another link", …}}. |
expires_at |
string | no | An ISO 8601 time such as 2026-12-31T23:59:00Z, stored in UTC. A time in the past creates an already-expired link. |
expired_url |
string | no | An http(s) URL taps go to once the link has expired. |
disabled, disabled_reason |
boolean, string | no | true creates the link paused. The reason is at most 255 characters. |
archived |
boolean | no | true creates the link archived. |
Checks run in this order and stop at the first failure:
| Status | Body |
|---|---|
| 400 | {"error": {"code": "invalid_request", "message": "Invalid JSON body"}} |
| 400 | {"error": {"code": "invalid_request", "message": "slug is required"}} |
| 400 | {"error": {"code": "invalid_request", "message": "Invalid slug: only letters, numbers, hyphens and underscores are allowed"}} |
| 400 | {"error": {"code": "invalid_request", "message": "Slug 'healthz' is reserved"}}: healthz and readyz (any case) are answered by the service itself on every host, so they are refused for permanent and temporary links |
| 400 | {"error": {"code": "invalid_request", "message": "Invalid target: must be a valid http(s) URL"}} |
| 400 | {"error": {"code": "invalid_request", "message": "disabled must be true or false"}}, "archived must be true or false", "expires_at must be an ISO 8601 time, e.g. 2026-12-31T23:59:00Z", "expired_url must be a valid http(s) URL", "disabled_reason: max 255 characters" |
| 409 | {"error": {"code": "conflict", "message": "Slug already exists", "slug": "spring-sale"}}, or the deleted-link variant above |
| 400 | {"error": {"code": "invalid_request", "message": "Unknown app_id"}} (the id is not one of your apps) |
| 400 | {"error": {"code": "invalid_request", "message": "Invalid label: max 255 characters"}}, {"error": {"code": "invalid_request", "message": "Invalid campaign: control characters are not allowed"}}, {"error": {"code": "invalid_request", "message": "Invalid adgroup: must be a string"}} (checked in the order label, campaign, adgroup, creative) |
| 400 | {"error": {"code": "invalid_request", "message": "Invalid ios_deep_link_path: must be a relative path, not a full URL (no \":\": no scheme, no spaces, no \"#\"/\";\")"}}, {"error": {"code": "invalid_request", "message": "Invalid android_deep_link_path: deep link path is too long (max 512 chars)"}} |
| 400 | {"error": {"code": "invalid_request", "message": "Invalid redirect_url: must be a valid http(s) URL"}}, same for fallback_url |
The server also rejects og_title or og_description longer than 150 characters and an og_image_url that is not an http(s) URL, with the 400 strings listed under Update a link.
dectog POST /api/v1/links '{ "slug": "spring-sale", "target": "https://shop.example.com/sale", "metadata": { "screen": "sale" }, "ios_deep_link_path": "sale", "android_deep_link_path": "sale", "campaign": "spring"}'dectog is the signing helper from API overview.
Create many links
Section titled “Create many links”POST /api/v1/links/bulk with {"links": [ … ]} creates up to 100 links, each with the create fields above. Every link succeeds or fails on its own, and the call answers 200:
{ "created": [ { "slug": "spring-1", "url": "https://acme.dectog.com/spring-1", … } ], "errors": [ { "index": 1, "slug": "spring-1", "code": "conflict", "error": "Duplicate slug or external_id in this batch" } ]}created holds full Link objects; errors names each failed item by its position in links, with the same code and message a single create would give. A slug or external id that appears twice in the batch fails from its second appearance. An empty array, more than 100 links, or a body without links answers 400. The dashboard’s Import CSV and the MCP create_links tool use this route.
Upsert and external ids
Section titled “Upsert and external ids”A link created with external_id can be named by it anywhere a v1 link route takes a slug: GET, PUT and DELETE /api/v1/links/ext_<id> and POST /api/v1/links/ext_<id>/restore, so a system that knows its own ids never has to store slugs.
PUT /api/v1/links/upsert makes a sync idempotent. It finds the link by external_id when the body has one, else by slug:
| Found | Result |
|---|---|
| A live link | Updated with the PUT rules (absent keeps, null or "" clears); 200 with the Link. A slug in the body that differs from the found link’s answers 400: a link’s slug never changes. |
| A deleted link | 409 with "deleted": true: restore it first |
| Nothing | Created with the create rules (slug is then required); 201 with the Link |
dectog PUT /api/v1/links/upsert '{"external_id": "product-42", "slug": "p42", "target": "https://shop.example.com/p/42", "campaign": "catalog"}'Templates
Section titled “Templates”A template holds defaults for link fields. A link with template_id takes the template’s value for every field it leaves empty, at tap time, so editing a template changes all its links at once. The link’s own values always win; see the precedence.
| Route | Response |
|---|---|
GET /api/v1/link-templates |
200 {"data": [Template, …], "next_cursor": null}, by name |
POST /api/v1/link-templates |
{name, app_id?, defaults} → 201 Template; a name already in use answers 409 |
GET /api/v1/link-templates/:id |
200 Template or 404 Template not found |
PUT /api/v1/link-templates/:id |
any of name, app_id, defaults (which replaces the whole set) → 200 Template |
DELETE /api/v1/link-templates/:id |
200 {"success": true}; its links keep their own fields and stop inheriting |
{ "id": "1b2c…", "name": "Spring campaign", "app_id": null, "defaults": { "fallback_url": "https://shop.example.com/spring", "campaign": "spring", "utm_source": "newsletter" }, "link_count": 12, "created_at": "2026-10-14T09:00:00.000Z", "updated_at": null}defaults may hold target, redirect_url, fallback_url, ios_deep_link_path, android_deep_link_path, og_title, og_description, og_image_url, label, campaign, adgroup, creative, the five utm_* fields, ios_tap_to_store and mobile_interstitial, each validated like the link field; any other key answers 400. name is 1 to 100 characters; app_id, when set, is the app new links get when they name none. Templates use the links scopes: reading needs links:read, changing links:write. Responses to GET /api/v1/links/:slug show the link’s own fields, not the inherited ones; resolve, the redirect and the install verdict use the inherited values.
List and read links
Section titled “List and read links”| Route | Response |
|---|---|
GET /api/v1/links?limit=&cursor=&include_deleted= |
200 {"data": [Link, …], "next_cursor": "…" | null}, newest first. limit is 1 to 200 (default 50); pass next_cursor back as cursor for the next page until it is null. A limit out of range, or a cursor this API did not return, answers 400. Paused, expired and archived links are listed; deleted ones only with include_deleted=true. |
GET /api/v1/links/:slug |
200 with the Link, or 404 {"error": {"code": "not_found", "message": "Link not found"}}. A temporary link’s slug and a deleted link also answer 404 here. |
Update a link
Section titled “Update a link”PUT /api/v1/links/:slug applies a partial update and returns 200 with the full Link. Unknown slugs and deleted links return 404 {"error": {"code": "not_found", "message": "Link not found"}}. The lifecycle fields of the create body work here too; see the next section.
| In the body | Effect |
|---|---|
| Key absent | The stored value is kept. This includes the og_* fields. |
null |
Clears the field: target, metadata, og_*, the deep-link paths, redirect_url, fallback_url and the four tracking fields all become null. |
"" |
Clears the field, metadata included. |
slug, app_id |
Ignored. Neither can be changed after creation. |
Validation is the same as on create. Errors come back as 400 with the same strings, plus {"error": {"code": "invalid_request", "message": "og_title must be at most 150 characters"}}, {"error": {"code": "invalid_request", "message": "og_description must be at most 150 characters"}} and {"error": {"code": "invalid_request", "message": "og_image_url must be a valid http(s) URL"}}.
dectog PUT /api/v1/links/spring-sale '{"target": null, "label": "launch-week"}'Geo and language rules
Section titled “Geo and language rules”A rule replaces some of the link’s fields for taps from given countries or browser languages. Rules are tried in order and the first match wins; a tap no rule matches is routed as usual, and the URL’s dec_* parameters still win over a rule. See How a tap is routed.
| Rule field | Type | Rules |
|---|---|---|
name |
string | Up to 64 letters, digits, spaces and . _ : -; recorded on each click the rule routes (routing_rule), else rule N |
countries |
array of strings | ISO 3166 two-letter codes, any case, stored upper case |
languages |
array of strings | Language tags; only the primary tag is kept, lower case (de-AT becomes de) |
percent |
integer | An A/B share of visitors, 1 to 100. Shares stack in rule order; together at most 100. A visitor’s share follows their hashed IP. |
starts_at, ends_at |
string | ISO 8601 times bounding the rule (scheduled routing); ends_at after starts_at |
target, redirect_url, fallback_url |
string | http(s) URLs that replace the link’s own for a matching tap |
ios_deep_link_path, android_deep_link_path |
string | In-app routes, with the same rules as the link’s |
A rule needs a condition (countries, languages, percent, starts_at or ends_at; a tap must meet every one a rule has) and at least one of the destination fields. Unknown fields, more than 20 rules, shares over 100, or a bad value answer 400, for example {"error": {"code": "invalid_request", "message": "routing_rules[0] needs a condition: countries, languages, percent, starts_at or ends_at"}}.
dectog PUT /api/v1/links/spring-sale '{"routing_rules": [ {"countries": ["DE", "AT", "CH"], "fallback_url": "https://shop.example.de/sale", "ios_deep_link_path": "de/sale", "android_deep_link_path": "de/sale"}, {"languages": ["fr"], "fallback_url": "https://shop.example.fr/soldes"}]}'dectog PUT /api/v1/links/spring-sale '{"routing_rules": [ {"name": "new-landing", "percent": 50, "fallback_url": "https://shop.example.com/new", "redirect_url": "https://shop.example.com/new-m"}]}'Pause, expire, archive and delete
Section titled “Pause, expire, archive and delete”A link that is not live records nothing: no click row, no click count, for people and bots alike. The SDK’s click and app-open reports for it are refused the same way, after the app has already opened.
| State | Set with | A tap gets | resolve, SDK clicks and opens |
|---|---|---|---|
| Paused | PUT {"disabled": true, "disabled_reason": "…"}; {"disabled": false} resumes and clears the reason |
A 410 “Link paused” page. The reason is never shown. |
410 {"error": {"code": "link_paused", …}} |
| Expired | expires_at in the past, on create or PUT; null or "" removes it |
A 302 to expired_url, or a 410 “Link expired” page without one |
410 {"error": {"code": "link_expired", "expired_url": "…" | null, …}} |
| Archived | PUT {"archived": true}; false unarchives |
The usual routing: archiving only files the link away in the dashboard | As a live link |
| Deleted | DELETE /api/v1/links/:slug |
A 404 page, as for a slug that never existed |
404 not_found |
Pausing a paused link keeps its first disabled_at. A paused link that has also expired answers as paused.
dectog PUT /api/v1/links/spring-sale '{"expires_at": "2026-12-31T23:59:00Z", "expired_url": "https://shop.example.com/sale-over"}'Delete, restore, delete permanently
Section titled “Delete, restore, delete permanently”DELETE /api/v1/links/:slug returns 200 {"success": true}. Unknown slugs, and links already deleted, return 404 {"error": {"code": "not_found", "message": "Link not found"}}.
Deleting is a soft delete. The link leaves the list (unless include_deleted=true), its URL answers 404, and GET and PUT answer 404. Its clicks and the installs, events and app opens attributed to it stay, so the Overview and every past verdict keep their numbers. The link keeps its slug. Thirty days after the delete, the daily job anonymises its clicks and app opens (IP hash, device id, user agent and the like), as retention would do after RETENTION_DAYS; installs and events keep the normal schedule.
POST /api/v1/links/:slug/restore brings a deleted link back and returns 200 with the Link; restoring a live link changes nothing. An unknown slug answers 404.
DELETE /api/v1/links/:slug?permanent=true deletes the link and its history in one atomic batch, on a live or a deleted link: the events of installs it attributed and events denormalized to its slug, its app opens and referrals, the installs attributed through its clicks (customers that pointed at those installs keep their identity but lose first_install_id), its clicks, then the link. The dashboard’s totals drop accordingly, and the slug is free again. This cannot be undone; see Dashboard: Links.
Temporary links
Section titled “Temporary links”Temporary links expire after a TTL. They have no app binding, no deep-link paths, no tracking defaults and no click counter; taps use the account’s default app for store routing and fallback. See Temporary links for how they behave when tapped.
{ "id": "9a7b5c3d-1e2f-4a6b-8c9d-0e1f2a3b4c5d", "slug": "t_4f1e9c2b7a", "target": "https://example.com/build/1234", "url": "https://acme.dectog.com/t_4f1e9c2b7a", "expires_at": "2026-10-12T10:00:00.000Z", "created_at": "2026-10-12T09:00:00.000Z", "ttl_seconds": 3600, "og_title": null, "og_description": null, "og_image_url": null, "expired_url": null, "disabled_at": null, "disabled_reason": null, "archived_at": null, "deleted_at": null}Create a temporary link
Section titled “Create a temporary link”POST /api/v1/temp-links returns 201 with the TempLink.
| Field | Type | Required | Meaning |
|---|---|---|---|
target |
string or null | no | Same rules as for permanent links. |
slug |
string | no | Same rules as for permanent links. When absent, null or "", the server generates t_ followed by 10 lowercase hex characters, for example t_4f1e9c2b7a. |
ttlSeconds |
number | no | Lifetime in seconds. Default 900 (15 minutes). |
og_title, og_description, og_image_url |
string | no | Same rules as for permanent links. |
expired_url, disabled, disabled_reason, archived |
no | As on permanent links. The expiry is always the TTL: expires_at in the body answers 400. |
How ttlSeconds is interpreted:
- It must be a JSON number when present, otherwise
400 {"error": {"code": "invalid_request", "message": "ttlSeconds must be a number"}}. - The value is floored to a whole number.
0, or anything that floors to0(for example0.5), becomes900. - Everything else is clamped to the range 60 to 2,592,000 (30 days):
30becomes60,-5becomes60,1e15becomes2592000. expires_atiscreated_atplus the resultingttl_seconds.
Other errors: 400 {"error": {"code": "invalid_request", "message": "Invalid slug"}}, 400 {"error": {"code": "invalid_request", "message": "Invalid target: must be a valid http(s) URL"}}, 409 {"error": {"code": "conflict", "message": "Slug already exists", "slug": "…"}}. Invalid og_* values are rejected as on permanent-link create.
dectog POST /api/v1/temp-links '{"target": "https://example.com/build/1234", "ttlSeconds": 3600}'Read, update and delete
Section titled “Read, update and delete”| Route | Response |
|---|---|
GET /api/v1/temp-links/:slug |
200 TempLink, or 404 {"error": {"code": "not_found", "message": "Temporary link not found"}}. |
PUT /api/v1/temp-links/:slug |
Partial update of target, the three og_* fields and the lifecycle fields; absent keeps, null or "" clears. Returns 200 TempLink, or 404 {"error": {"code": "not_found", "message": "Temporary link not found"}}. The TTL and expires_at cannot be changed. |
DELETE /api/v1/temp-links/:slug |
200 {"success": true} after a soft delete, as for a permanent link; ?permanent=true deletes the link with the same history cascade. 404 {"error": {"code": "not_found", "message": "Temporary link not found"}} for a slug that is not yours. |
POST /api/v1/temp-links/:slug/restore |
200 TempLink after restoring a deleted temporary link, or 404. |
There is no list route for temporary links in the v1 API. The dashboard’s GET /api/admin/links returns them under temp_links.
Lifecycle after expiry
Section titled “Lifecycle after expiry”- From
expires_aton, a tap goes to the link’sexpired_url, or answers the410“Link expired” page, for bots too. No click is recorded. The link still exists. resolveanswers410 link_expired;GET /api/v1/temp-links/:slugkeeps answering200.- The daily job (03:17 UTC) deletes temporary links whose
expires_atis more than 24 hours in the past, except those with anexpired_url, which keep sending taps there until you delete them. Their clicks and installs are kept, and the slug is free again. From then on the link URL answers 404 and the API answers404. - The dashboard’s “Delete expired” button (
DELETE /api/admin/temp-links/expired) removes every expired temporary link of the account immediately, also keeping their history.
Resolve a link (public)
Section titled “Resolve a link (public)”GET /api/v1/resolve/:username/:slug needs no API key, no signature and is not rate limited. It is what the SDK calls when it opens a link, and it works for any account’s links. CORS allows every origin.
{ "id": "6f1c2a0e-3b7d-4c8e-9a11-2f5d8c0b4e77", "slug": "spring-sale", "target": "https://shop.example.com/sale", "url": "https://acme.dectog.com/spring-sale", "type": "permanent", "created_at": "2026-10-12T09:00:00.000Z", "click_count": 12, "expires_at": null, "ttl_seconds": null, "metadata": { "screen": "sale", "campaign": "spring" }, "ios_deep_link_path": "sale", "android_deep_link_path": "sale"}Every key is always present. Which ones carry values depends on the kind of link:
| Field | Permanent link | Temporary link |
|---|---|---|
type |
"permanent" |
"temporary" |
click_count |
number | null |
expires_at |
the expiry, or null |
value |
ttl_seconds |
null |
value |
metadata |
JSON or null |
null |
ios_deep_link_path, android_deep_link_path |
string or null |
null |
id, slug, target, url and created_at are filled for both kinds. Only a live link resolves: a paused one answers 410 {"error": {"code": "link_paused", "message": "This link is paused"}}, an expired one 410 {"error": {"code": "link_expired", "message": "This link has expired", "expired_url": "…"}} (null without one), and an unknown username or slug, or a deleted link, 404 {"error": {"code": "not_found", "message": "Link not found"}}. The og_*, routing and tracking fields are not exposed here.