Landing pages
Build a per-domain link-in-bio funnel — geo-segments, button stacks, a notification banner, and a background — then preview and publish it.
A landing page is the public funnel TEASE serves on one of your domains. You compose it from ordered geo-segments, a stack of buttons per segment, an optional notification banner, and a background — all edited as a draft, previewed live, then published in one atomic step.
Every change you make through the API writes to the draft state only. The live page never moves
until you call /publish. That gives you a safe edit-preview-ship loop
with a single global undo (/discard).
Build a page
Connect a domain
Ship it
Concepts
| Concept | What it is |
|---|---|
| Domain | A host your public page is served on (e.g. links.creator.com). Every other resource on this page is scoped to a domain. |
| Geo-segment | An ordered audience bucket matched by country (and, optionally, device, OS, time, or language). Each segment owns its own button stack. Exactly one segment is the default catch-all. |
| Button | A labelled link inside a segment, with visible text, an icon, and a destination URL. Buttons are ordered within a segment. |
| Teaser popup | An optional per-button card (photo + short text) that slides out under its button after a delay. |
| Notification banner | A push-style banner (avatar, title, body, call-to-action) that surfaces on the page after a short delay. |
| Background | The page backdrop — a mobile image, a desktop image, and a solid canvas colour behind the card on desktop. |
| Page UI style | Landing-wide button appearance, the profile-card look, and an optional 18+ confirmation gate — applies while the domain uses a built-in (platform) design. |
| Draft / Published | Two snapshots of the whole page. You always edit the draft; /publish copies draft → published; /discard copies published → draft (undo). |
Authentication
Every endpoint on this page lives under https://app.tease.link and requires a Bearer key with
the write scope (reads accept any key). See Authentication.
A few owner-only endpoints are called out inline.
Domains
A domain row is the anchor for an entire landing page. List the domains you own, create new ones, and copy a finished page onto another domain.
List domains
curl https://app.tease.link/api/admin/domains \
-H "Authorization: Bearer $TEASE_API_KEY"const res = await fetch('https://app.tease.link/api/admin/domains', {
headers: { Authorization: `Bearer ${process.env.TEASE_API_KEY}` },
});
const { items } = await res.json();import os, requests
res = requests.get(
"https://app.tease.link/api/admin/domains",
headers={"Authorization": f"Bearer {os.environ['TEASE_API_KEY']}"},
)
items = res.json()["items"]{
"items": [
{
"hostname": "links.creator.com",
"display_name": "Instagram bio",
"created_at": 1751280000,
"button_count": 4,
"infra_state": "ssl_active",
"provisioning_error": null,
"infra_attempted_at": 1751280000
}
]
}Each row carries an infra_state that drives the status badge in the dashboard:
infra_state | Meaning |
|---|---|
unmanaged | A bare domain row with no managed certificate. |
provisioning | Setup is running (TLS issuance) right after creation. |
pending_dns | Waiting on DNS to point at TEASE — see custom domains. |
ssl_active | Certificate issued and the page is serving over HTTPS. |
failed | The last setup attempt failed; provisioning_error carries a message. |
Create a domain
curl -X POST https://app.tease.link/api/admin/domains \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "hostname": "links.creator.com", "display_name": "Instagram bio" }'const res = await fetch('https://app.tease.link/api/admin/domains', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TEASE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ hostname: 'links.creator.com', display_name: 'Instagram bio' }),
});
const domain = await res.json();res = requests.post(
"https://app.tease.link/api/admin/domains",
headers={"Authorization": f"Bearer {os.environ['TEASE_API_KEY']}"},
json={"hostname": "links.creator.com", "display_name": "Instagram bio"},
)
domain = res.json()Parameters
| Field | Type | Required | Description |
|---|---|---|---|
hostname | string | yes | A valid host (my-site.com), 1–253 chars, lowercased. No scheme, no path. |
display_name | string | no | A friendly "traffic source" label shown in reports (e.g. FB Agency). Blank clears it. |
A new domain is created with infra_state: "provisioning" and seeded with three default
geo-segments plus empty notification/background rows, so it is editable immediately. Re-creating a
hostname you already own within a minute returns the existing row (idempotent retry); otherwise a
duplicate returns 409 domain_taken.
Plan limits
Each plan caps how many domains you can own. Creating one past your cap returns
409 domain_cap_reached.
Rename or delete a domain
PATCH updates the display_name (it is reporting metadata only and takes effect without a
publish). DELETE removes the domain and cascades its buttons, segments, banner, and background.
# Rename
curl -X PATCH https://app.tease.link/api/admin/domains/links.creator.com \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "display_name": "TikTok bio" }'
# Delete
curl -X DELETE https://app.tease.link/api/admin/domains/links.creator.com \
-H "Authorization: Bearer $TEASE_API_KEY"await fetch('https://app.tease.link/api/admin/domains/links.creator.com', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.TEASE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ display_name: 'TikTok bio' }),
});requests.patch(
"https://app.tease.link/api/admin/domains/links.creator.com",
headers={"Authorization": f"Bearer {os.environ['TEASE_API_KEY']}"},
json={"display_name": "TikTok bio"},
)Copy a page onto another domain
copy-from replaces the target's draft buttons and segments with copies of a source
domain's — a full draft clone you then publish. The page is rebuilt with fresh button identities so
the live page refreshes cleanly.
curl -X POST https://app.tease.link/api/admin/domains/new-site.com/copy-from \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "source": "links.creator.com" }'| Field | Type | Required | Description |
|---|---|---|---|
source | string | yes | The hostname to copy the draft page from. Must differ from the target. |
Companion endpoints copy a single part across domains: copy-notification-from (banner) and
copy-bg-from (background). POST /api/admin/bulk/copy-content copies selected parts
(buttons_segments, bg, notification, canvas_color) from one source onto many targets in a
single call.
Custom domains
To serve a page on your own domain, connect it and verify DNS. Connect is a small state machine
built on infra_state: a connect request opens the domain in pending_dns, and a successful
verify flips it to active.
Start the connect
POST /api/admin/domains/connect registers the host and returns the exact DNS record to add. A
subdomain (e.g. links.creator.com) gets a CNAME instruction; a bare apex (e.g. creator.com)
gets an A record instruction. The response also includes the URL to call to verify.
curl -X POST https://app.tease.link/api/admin/domains/connect \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "hostname": "links.creator.com" }'{
"domain_id": 42,
"hostname": "links.creator.com",
"is_apex": false,
"state": "pending_dns",
"instructions": { "record_type": "CNAME", "name": "links", "value": "…" },
"verify_url": "/api/admin/domains/42/verify"
}| Field | Type | Required | Description |
|---|---|---|---|
hostname | string | yes | The custom host to connect. A host already taken returns 409 domain_taken. |
provider | string | no | A DNS provider hint so the returned instructions match your registrar's UI. |
Add the DNS record
In your DNS provider, create the record exactly as returned in instructions. CNAME records can
take a few minutes to propagate; apex A records can take longer.
Verify
Call the verify_url (or POST /api/admin/domains/{domain_id}/verify). While DNS has not landed
yet, the response stays in pending_dns and lists what is still missing in issues:
{
"domain_id": 42,
"hostname": "links.creator.com",
"state": "pending_dns",
"issues": ["CNAME not found"]
}Once the record resolves correctly, verify flips the domain to active and returns the live URL:
{
"domain_id": 42,
"hostname": "links.creator.com",
"state": "active",
"url": "https://links.creator.com"
}The state machine is intentionally small — there are only two outcomes from pending_dns:
| State | How you get there | What it means |
|---|---|---|
pending_dns | After connect, and after any verify while DNS is not yet correct. | TEASE is waiting on your DNS record. |
active | After a verify where the DNS record resolves correctly. | The page serves over HTTPS on your domain. |
Re-run verify as often as you like — it is idempotent and safe to poll while DNS propagates.
Segments
A geo-segment is an audience bucket. Visitors are matched to the first segment whose conditions fit, falling through to the one default catch-all. Each segment owns its own ordered button stack, so you can show a different funnel to (say) US mobile traffic than to everyone else.
A segment matches primarily by country. You can optionally narrow it with display conditions on device, OS, or time of day, and tag it with a target language for auto-translation.
Segments have their own «Сегменты» tab in the domain hub (/domains/<domain>/segments), next
to «Кнопки». It lists every segment as a card in priority order (top matches first) — its countries
and conditions, the default / block flags, the button-set A/B split, and up/down priority
arrows. The segment's actual buttons are still edited in the «Кнопки» tab.
Country tiers
Your account has three reusable country tiers, shared across every domain:
- Tier 1 — your top-paying markets.
- Tier 2 — your mid markets.
- Blocklist — countries you turn away with a
403(they never enter the funnel).
They power two things: the T1 / T2 preset chips in the segment editor add the countries from your Tier 1 / Tier 2 to the segment you're building, and traffic is automatically A/B-weighted by tier (Tier 1 leans more aggressively toward the paid offer than Tier 2).
The tiers stay behind the scenes — they seed the preset chips and the A/B split, not a settings
screen of their own. Every account starts with sensible defaults; to customise them, use the API/MCP
get_config / set_config (the geo_classify key — { "tier1": [...], "tier2": [...], "blocklist": [...] }
of ISO-3166 alpha-2 codes). Your changes apply only to your account.
List segments
curl https://app.tease.link/api/admin/segments/links.creator.com \
-H "Authorization: Bearer $TEASE_API_KEY"{
"domain": "links.creator.com",
"items": [
{
"segment_id": "s_1a2b3c4d",
"domain": "links.creator.com",
"name": "US mobile",
"countries": ["US"],
"position": 0,
"is_default": false,
"match": [{ "type": "device", "op": "is", "values": ["mobile"] }],
"ab": null,
"lang": ""
},
{
"segment_id": "s_default",
"domain": "links.creator.com",
"name": "Everyone",
"countries": [],
"position": 1,
"is_default": true,
"match": null,
"ab": null,
"lang": ""
}
]
}| Field | Type | Description |
|---|---|---|
segment_id | string | Stable id (s_<hex>). Used as the tier when managing this segment's buttons. |
name | string | A label you choose. |
countries | string[] | ISO-3166 alpha-2 codes this segment targets. Empty = no country restriction. |
position | int | Order in which segments are considered. |
is_default | bool | Whether this is the catch-all. Exactly one segment per domain is the default. |
match | object[] | null | Optional display conditions (device / OS / time). null = country only. |
ab | object | null | Optional button-set A/B configuration for the segment. |
lang | string | Optional target language (ISO code) for auto-translation, or "". |
Create a segment
curl -X POST https://app.tease.link/api/admin/segments/links.creator.com \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "US mobile",
"countries": ["US"],
"match": { "all": [{ "type": "device", "op": "is", "values": ["mobile"] }] }
}'Parameters
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | 1–64 chars. |
countries | string[] | no | ISO-3166 alpha-2 codes (US, GB). De-duplicated and upper-cased. Empty = no country restriction. |
is_default | bool | no | Make this the catch-all. The first segment on a domain always becomes the default. |
match | object | no | Display conditions, { "all": [<clause>, …] }. Clauses are AND-ed. Omit for country-only matching. |
lang | string | no | Target language code for auto-translation (see translations). |
Match clauses
A match clause narrows a segment beyond country. Each clause is one of:
| Clause | Shape | Allowed values |
|---|---|---|
device | { "type": "device", "op": "is" | "is_not", "values": [...] } | mobile, tablet, desktop |
os | { "type": "os", "op": "is" | "is_not", "values": [...] } | ios, android, windows, macos, other |
time | { "type": "time", "tz": "<IANA tz>", "days": [0–6], "hours": [[start, end]] } | days 0=Mon…6=Sun; hours are half-open [start, end) ranges with start under end, both 0–24 |
Within a clause, values are OR-ed; across clauses in all, conditions are AND-ed.
The default is the catch-all
Every domain keeps exactly one default segment. Promoting another segment to default
(is_default: true) clears the flag on the previous one automatically; you cannot clear the only
default directly.
Edit, reorder, delete
PATCH accepts any subset of the create fields. Omitting a field leaves it unchanged; sending an
explicit empty conditions payload clears it.
# Edit
curl -X PATCH https://app.tease.link/api/admin/segments/links.creator.com/s_1a2b3c4d \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "countries": ["US", "CA"] }'
# Reorder — the list must be a permutation of the domain's current segment ids
curl -X PUT https://app.tease.link/api/admin/segments/links.creator.com/order \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "order": ["s_1a2b3c4d", "s_default"] }'
# Delete — pass ?force=true to also remove the segment's buttons
curl -X DELETE "https://app.tease.link/api/admin/segments/links.creator.com/s_1a2b3c4d" \
-H "Authorization: Bearer $TEASE_API_KEY"Order matters: segments are considered top-to-bottom, so place your most specific segments above the
catch-all. Reorder validates that your order array is exactly the current set of segment ids
(order_mismatch otherwise). Deleting a segment that still has buttons returns
409 segment_has_buttons unless you pass ?force=true; deleting the default returns
400 segment_default_required until you promote another segment first.
Direct redirects
Sometimes the landing itself is the wrong layer for an audience — you want the visitor on the
destination instantly. Two levels of direct 307 redirects cover that, and both keep every hop
logged and attributed like any other click.
Redirect segments
A segment can be flagged as a redirect segment: a visitor it matches never sees the landing —
they are 307-redirected straight to that segment's primary button destination (the bottom-most
one, the same button the landing paints as its main CTA). The hop is recorded as a click with its own
source label, so redirected countries stay visible in your traffic reports and feed
Telegram attribution. A block segment always wins over a redirect
— a blocked country still gets its 403. The flag itself is provisioned by the platform for
messenger-bound geo routes; it is not yet self-serve in the segment editor.
The destination lives in the BUTTON (fixed 2026-07-27)
Until 27 Jul 2026 the destination came from the TG_DIRECT_ACCOUNT_USERNAME server setting — one
Telegram account for the whole platform, and only for the platform owner's domains: a tenant's
redirect segment did not work at all, and the segment's buttons took no part whatsoever (editing a
button changed nothing on the site). The target is now the same primary button the per-domain
direct-redirect uses: each creator's own destination, visible and editable from the panel. No
visible button with a destination → the landing is served (fail-safe).
In the panel such an audience carries a «→ редирект» badge, a notice above the list spells out the real destination, the target button is labelled, and the rest are dimmed.
Direct-redirect mode (per domain)
A whole domain can skip its landing: with direct-redirect mode on, TEASE geo-matches the
visitor to a segment as usual and then 307s them straight to that segment's button destination —
for a smart-link button that is the domain's /r/<slug> smart link,
so click logging, click-id binding, and revenue attribution keep working end-to-end. Built for
placements where an interstitial page only loses clicks (e.g. adult X/Twitter traffic).
curl -X POST https://app.tease.link/api/admin/domains/links.creator.com/direct-redirect \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "enabled": true, "ab_pct": 0 }'{ "domain": "links.creator.com", "direct_redirect": true, "ab_pct": 0 }| Field | Type | Required | Description |
|---|---|---|---|
enabled | bool | yes | Turn the mode on or off. Turning it off also resets the stored ab_pct to 0, so a later re-enable starts clean (everyone redirected). |
ab_pct | int | no | A/B share, 0–100 — see below. Omit to keep the stored value; ignored while enabled is false. |
The guardrails you'd expect all stay on:
- The redirect target comes from the published page, so the toggle needs no re-publish and takes effect within about a minute. In the panel it is the «Прямой редирект» button next to «Импорт», above the button list.
- The target is the audience's primary button — the bottom-most one, the same button the landing paints as its main CTA. The setting is per DOMAIN: it applies in EVERY audience, and each one sends its visitors to its own primary button.
- Redirect and block segments still win: a matched redirect segment sends its own 307 (e.g.
to Telegram), and a blocklisted country still gets
403. - Bots and scanners never see the destination — they get nothing, exactly like the cloaking layer.
- In-app webviews (Instagram / Facebook / TikTok embedded browsers) still receive the landing: the destination won't open inside a hardened webview, so the landing's client-side escape must run there.
- If the matched segment has nothing to redirect to, the landing is served — a fail-safe, never a dead redirect.
The panel shows each audience's fate (since 2026-07-27)
While the mode is on, the button list no longer looks like a normal page: the audience carries a «→ редирект» badge (or «→ N%» under A/B), a notice above the list says «visitors do NOT see the page, they go straight to …» with the real destination, the target button is labelled «сюда уходит редирект», and the rest are dimmed with «посетитель не увидит». Before this the panel drew every button the same way, hiding the fact that a visitor only ever reaches one.
Changing the target happens in the same dialog: the chosen button is moved to primary. The other buttons stay put — under A/B the landing arm still shows them.
A/B: landing vs. straight-to-offer
ab_pct turns the on/off into a live experiment on an enabled host: N% of visitors get the
direct redirect (arm B), the rest get the normal landing funnel (arm A). 0 means everyone is
redirected (no A/B); 100 states the same thing explicitly. The split is sticky per visitor and
cookieless, and entry clicks carry the arm tag, so conversions attribute per arm — you measure
which converts better instead of committing 100%. Domain rows in GET /api/admin/domains report
direct_redirect and direct_redirect_ab_pct.
Buttons
Buttons live inside a segment. When managing buttons, the segment's segment_id is the tier path
parameter — so buttons/{domain}/{segment_id} is "the buttons shown to this audience".
List a segment's buttons
curl https://app.tease.link/api/admin/buttons/links.creator.com/s_1a2b3c4d \
-H "Authorization: Bearer $TEASE_API_KEY"{
"domain": "links.creator.com",
"tier": "s_1a2b3c4d",
"items": [
{
"button_id": "main",
"domain": "links.creator.com",
"tier": "s_1a2b3c4d",
"position": 0,
"label": "Subscribe",
"text_value": "Subscribe",
"url": "https://example.com/offer",
"icon": "heart.svg",
"hidden": false,
"match": null,
"variants": null,
"popup": null
}
]
}| Field | Type | Description |
|---|---|---|
button_id | string | Stable id within the segment. Server-generated when you omit it on create. |
position | int | Order within the segment. |
text_value | string | The visible button text. |
label | string | An internal alias for logs/reports; defaults to text_value. |
url | string | The destination the button points to. |
icon | string | The icon asset filename (name.svg) — see Icons. |
hidden | bool | Whether the button is hidden on the live page. |
match | object[] | null | Optional per-button display conditions (same clause shapes as segments). |
variants | object[] | null | Optional URL A/B variants for the button. |
popup | object | null | The button's teaser popup, or null when off. |
Create a button
curl -X POST https://app.tease.link/api/admin/buttons/links.creator.com/s_1a2b3c4d \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text_value": "Subscribe",
"url": "https://example.com/offer",
"icon": "heart.svg"
}'Parameters
| Field | Type | Required | Description |
|---|---|---|---|
text_value | string | yes | Visible button text, 1–64 chars. |
url | string | yes | Destination, http(s)://, up to 2048 chars. |
icon | string | yes | Icon filename matching [A-Za-z0-9_-].svg. |
label | string | no | Internal alias for reports. Defaults to text_value. |
button_id | string | no | Provide your own id, else one is generated. |
match | object | no | Per-button display conditions, { "all": [<clause>, …] }. |
variants | object | no | URL A/B set, { "variants": [{ "id", "weight", "url" }, …] } (2 or more variants). |
popup | object | no | A teaser popup for the button. |
Editing and ordering mirror segments:
# Edit any subset of fields
curl -X PATCH https://app.tease.link/api/admin/buttons/links.creator.com/s_1a2b3c4d/main \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "text_value": "Join now" }'
# Reorder within the segment
curl -X PUT https://app.tease.link/api/admin/buttons/links.creator.com/s_1a2b3c4d/order \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "order": ["main", "secondary"] }'
# Delete
curl -X DELETE https://app.tease.link/api/admin/buttons/links.creator.com/s_1a2b3c4d/main \
-H "Authorization: Bearer $TEASE_API_KEY"A/B in brief
Supplying two or more variants turns a button into an A/B test across destination URLs, weighted
by weight. TEASE handles serving and keeping a given visitor on a consistent variant — you only
declare the variants and read the results in your reports.
Teaser popup
Any button can carry a teaser popup — a small card with a photo and up to 160 characters of text that slides out under the button after a delay. Tapping the card clicks the button itself. A popup shows at most once per pageview, and a visitor who dismisses it won't see it again for the rest of their session.
Configure it in two steps: upload the photo (auto-cropped to a 480×360 WebP card), then set the
popup object on the button via create or PATCH.
# 1. Upload the card photo (multipart, max 5 MB)
curl -X POST https://app.tease.link/api/admin/buttons/links.creator.com/s_1a2b3c4d/main/popup-photo \
-H "Authorization: Bearer $TEASE_API_KEY" \
-F "file=@teaser.jpg"{ "filename": "popup_9f2c1a7b3d4e.webp", "url": "/notification/links.creator.com/popup_9f2c1a7b3d4e.webp" }# 2. Attach the popup to the button
curl -X PATCH https://app.tease.link/api/admin/buttons/links.creator.com/s_1a2b3c4d/main \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"popup": {
"enabled": true,
"text": "Tonight’s set is 50% off — 2 hours only",
"delay_ms": 4000,
"photo": "popup_9f2c1a7b3d4e.webp"
}
}'Popup fields
| Field | Type | Required | Description |
|---|---|---|---|
enabled | bool | yes | false (or empty text) clears the popup. |
text | string | yes | The card text, up to 160 chars. |
delay_ms | int | no | Delay before the card slides out, 500–30000 ms (default 4000). |
photo | string | no | A filename returned by the popup-photo upload. |
The popup object replaces the stored popup wholesale. Re-uploading identical photo bytes is
idempotent (same content → same filename), so retries are safe.
Icons
Every button references an icon by filename. The icon library ships ~290 built-in icons — brand marks plus generic glyphs, searchable and categorised in the panel's icon picker — and holds your own uploads next to them. Built-ins are shared; anything you upload or generate is private to your workspace.
# List everything you can use (built-ins + your own)
curl https://app.tease.link/api/admin/icons \
-H "Authorization: Bearer $TEASE_API_KEY"
# Upload your own SVG (sanitized, max 1 MB)
curl -X POST https://app.tease.link/api/admin/icons \
-H "Authorization: Bearer $TEASE_API_KEY" \
-F "file=@custom.svg"Generate an icon with AI
Can't find the right glyph? POST /api/admin/icons/generate draws a new icon in the house
style — white on a transparent background, so it sits next to the built-ins seamlessly — and
saves it straight to your private library.
curl -X POST https://app.tease.link/api/admin/icons/generate \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "prompt": "paper plane", "style": "line" }'{ "filename": "gen_7c2f91d3a4b5.svg", "builtin": false, "uploaded_at": 1752537600 }| Field | Type | Required | Description |
|---|---|---|---|
prompt | string | yes | The icon concept, in any language, up to 200 chars. |
style | string | no | line (stroke outline, default) or solid (filled glyph). |
Use the returned filename as a button's icon. Generation is rate-limited to 6 per
minute (429 icon_gen_rate_limited); a failed attempt returns 502 icon_ai_failed — just
retry with a reworded prompt.
Notification banner
The banner is a per-domain, push-style card (avatar, title, body, and a call-to-action link) that appears on the page after a short delay. There is one banner per domain — you can edit it by hand, or drive it from the reusable notification library (where two or more enabled presets turn it into a creative A/B test).
Read and edit
curl https://app.tease.link/api/admin/notification/links.creator.com \
-H "Authorization: Bearer $TEASE_API_KEY"{
"domain": "links.creator.com",
"enabled": true,
"avatar_filename": "av_9f.png",
"sound_filename": null,
"title": "New drop",
"body": "Tap to see today's set",
"link_text": "Open",
"target_url": "https://example.com/offer",
"delay_ms": 2500,
"translations": {},
"style": null,
"preset_id": null,
"assignment": null
}PATCH accepts any subset of the editable fields:
curl -X PATCH https://app.tease.link/api/admin/notification/links.creator.com \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"title": "New drop",
"body": "Tap to see today’s set",
"link_text": "Open",
"target_url": "https://example.com/offer",
"delay_ms": 2500
}'Parameters
| Field | Type | Required | Description |
|---|---|---|---|
enabled | bool | no | Whether the banner shows on the live page. |
title | string | no | Banner headline, up to 128 chars. |
body | string | no | Banner text, up to 256 chars. |
link_text | string | no | Call-to-action label, up to 64 chars. |
target_url | string | no | Where the call-to-action goes (http(s)://). Required to enable only in the manual link mode; in smart-link mode it's just the last-resort fallback. Empty clears it. |
link_mode | string | no | Where a click goes: "" (default) = smart link — mirror the matched segment's primary button; "manual" = own link — target_url wins. See below. |
delay_ms | int | no | Delay before the banner appears, 500–10000 ms. |
translations | object | no | Per-locale overrides of the public text — see translations. |
assignment | object | no | Which library presets this banner serves, { "items": [...] } — see Notification library & A/B. { "items": [] } clears it. |
style | object | no | Banner appearance overrides — see Appearance. {} resets to the stock look. |
Where the click goes
The banner's call-to-action has two modes, switched by link_mode:
- Smart link (
"", the default) — a click mirrors the primary button of the segment the visitor matched, following the exact tier-aware routing (and A/B arm) the buttons themselves use. No URL is needed to enable the banner in this mode; a storedtarget_urlserves only as a last-resort fallback when the matched segment has no button to mirror. - Own link (
"manual") — the banner goes exactly where you point it:target_urlis required (enabling without it returns400 notification_incomplete) and wins over the segment mirror.
Appearance
The banner's look is styled independently of its content: the style object on the same
PATCH carries the appearance knobs. Only the keys you set are stored; send {} to reset the
banner to the stock look. The current value comes back in the style field on reads.
curl -X PATCH https://app.tease.link/api/admin/notification/links.creator.com \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"style": {
"theme": "glass",
"opacity": 0.9,
"radius": 18,
"avatar_shape": "circle",
"position": "top",
"accent": "#e0426e"
}
}'| Key | Type | Description |
|---|---|---|
theme | string | light, dark, or glass. |
opacity | float | Card opacity, 0.3–1. |
radius | int | Corner radius in px, 0–32. |
avatar_shape | string | circle, rounded, or square. |
position | string | top or bottom — the edge the banner slides in from. |
accent | string | Accent colour for the call-to-action, #rrggbb. |
dim | float | Readability scrim behind the banner text, 0–0.8. Omitted = an automatic 0.22 scrim on the glass themes; an explicit 0 turns it off. |
text_size | float | Banner text size multiplier, 0.85–1.3. |
font | string | Banner typeface — same font list as the button font. |
Avatar and sound
The avatar image and optional notification sound are uploaded as files and can be removed:
# Upload an avatar (multipart)
curl -X POST https://app.tease.link/api/admin/notification/links.creator.com/avatar \
-H "Authorization: Bearer $TEASE_API_KEY" \
-F "file=@avatar.png"
# Remove the sound
curl -X DELETE https://app.tease.link/api/admin/notification/links.creator.com/sound \
-H "Authorization: Bearer $TEASE_API_KEY"Notification library & A/B
Instead of hand-editing every domain, keep your banner creatives as reusable presets in the notification library and assign them to domains. In the panel, the «Уведомление» tab is a wall of preset cards with checkboxes: a checked card is what the domain shows, two or more checked cards run a creative A/B test, and each card's weight sets its share of impressions. A given visitor sticks to one variant (rotation is sticky per subnet), so the test stays clean.
A preset carries the full creative — texts, the call-to-action link, delay, translations, plus its
own master files (avatar/sound), stored under notification/_preset_<id>/ and served at
/notification/_preset_<id>/<filename>. Whenever a preset is put on a domain, those masters are
copied into the domain's folder, so the live banner never references a file that isn't there.
# List the library
curl https://app.tease.link/api/admin/library/notifications \
-H "Authorization: Bearer $TEASE_API_KEY"
# Save a domain's current banner into the library — files included
curl -X POST https://app.tease.link/api/admin/library/notifications/from-domain \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "domain": "links.creator.com", "name": "July drop" }'from-domain snapshots the domain's draft banner into a new preset together with its
avatar/sound files and stamps the new preset_id back onto the domain, so the card shows its
library binding. Errors: 400 name_required / 400 notification_empty (nothing to save),
409 preset_name_taken, 404 domain_not_found.
Assign presets to a domain
The assignment object on the banner PATCH is the API behind the checkboxes.
It replaces the stored assignment wholesale; { "items": [] } clears it (back to the hand-edited
single banner).
curl -X PATCH https://app.tease.link/api/admin/notification/links.creator.com \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"assignment": {
"items": [
{ "preset_id": 7, "weight": 2, "enabled": true },
{ "preset_id": 9, "weight": 1, "enabled": true },
{ "preset_id": 4, "enabled": false }
]
}
}'| Field | Type | Required | Description |
|---|---|---|---|
preset_id | int | yes | A preset id from GET /api/admin/library/notifications. |
weight | int | no | Relative share of impressions in a 2+ preset A/B (default 1). Here preset 7 serves ~2/3 of visitors. |
enabled | bool | no | The checkbox (default true). A disabled item stays in the list but never serves. |
Every enabled preset is validated on save: an unknown id returns 404 preset_not_found, and a
preset that is missing its texts, avatar, or — in the own-link mode — its link returns
400 preset_incomplete — a broken arm
can never silently eat a share of your traffic. The first enabled preset is stamped into the
domain banner (texts, files, and preset_id) and every enabled preset's master files are copied
into the domain folder, so a single-preset assignment serves its creative immediately.
Preset avatars
Each preset owns a master avatar, managed on the preset itself (multipart image up to 5 MB, center-cropped to a 192 px square WebP with a content-hash filename):
# Upload / replace the master avatar
curl -X POST https://app.tease.link/api/admin/library/notifications/7/avatar \
-H "Authorization: Bearer $TEASE_API_KEY" \
-F "file=@avatar.jpg"
# Remove it (bound domains lose it too and their banners are disabled)
curl -X DELETE https://app.tease.link/api/admin/library/notifications/7/avatar \
-H "Authorization: Bearer $TEASE_API_KEY"In the panel you can pick the avatar straight from your photo gallery or upload a new image — an uploaded photo is saved into the gallery as well, so it's reusable next time.
Editing and applying presets
PATCH /api/admin/library/notifications/{preset_id} edits propagate immediately: every domain
whose assignment enables the preset is re-stamped and its draft re-baked, so a text or avatar fix
reaches all bound drafts in one call — going live stays an explicit /publish.
POST /api/admin/library/notifications/{preset_id}/apply (body { "domains": [...] }) stamps a
preset onto whole domains at once: it writes a single-preset assignment, materializes the master
files into each domain folder, and enables the banner only when the preset is complete (all
texts, an avatar, and — in the own-link mode — the link).
Enabling follows preset readiness. On every re-stamp the bound domains' enabled is derived
from the preset itself: the assignment checkbox records the intent to show, and the banner turns
on automatically the moment the preset becomes complete (all texts, an avatar, and — in the own-link mode — the link) —
say, when the missing avatar finally lands in the card — and honestly turns off when the preset
stops being complete.
Background
Each domain has a background made of up to three pieces: a mobile image, a desktop image, and a solid canvas colour painted behind the card on desktop. Images go through an upload step and an edit step; the canvas colour is a single call.
Quick pick: in the «ник модели» card (the «Кнопки» tab), clicking the avatar opens the background library — choose one (→ single bg) or several (→ random rotation) without leaving the buttons editor. Uploading and cropping new backgrounds still lives in the «Дизайн» tab.
Upload and edit an image
bg/upload saves the original image for a (domain, variant) pair, where variant is mobile or
desktop. bg/edit applies your crop/transform design state and regenerates the served variants.
# 1. Upload the source image for the mobile background
curl -X POST "https://app.tease.link/api/admin/bg/upload?domain=links.creator.com&variant=mobile" \
-H "Authorization: Bearer $TEASE_API_KEY" \
-F "file=@bg-mobile.jpg"
# 2. Commit the editor design state
curl -X POST "https://app.tease.link/api/admin/bg/edit?domain=links.creator.com&variant=mobile" \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "designState": { } }'const form = new FormData();
form.append('file', file); // a Blob/File of the image
await fetch(
'https://app.tease.link/api/admin/bg/upload?domain=links.creator.com&variant=mobile',
{ method: 'POST', headers: { Authorization: `Bearer ${process.env.TEASE_API_KEY}` }, body: form },
);| Query / body | Type | Required | Description |
|---|---|---|---|
domain | query | yes | The domain to set the background for. |
variant | query | yes | mobile or desktop. |
file | multipart | yes (upload) | The source image. |
designState | body | yes (edit) | The editor's crop/transform state object. Pass {} to use the image as-is. |
Canvas colour
curl -X POST "https://app.tease.link/api/admin/bg/canvas-color?domain=links.creator.com" \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "color": "#0d0604" }'| Field | Type | Required | Description |
|---|---|---|---|
color | string | yes | A #rrggbb hex colour. |
Button appearance
Built-in (platform) designs expose a landing-wide button style — shape, fill, opacity,
colours, label contrast, typography, icon colours, alignment, and an idle animation. It follows
the same draft → publish lifecycle as everything else and is set through POST /api/admin/bg/ui,
which carries three independent sections: buttons (this one), profile, and
gate. In the panel they live in the domain hub («Дизайн» / «Лендинг»),
with a live preview of every knob.
A section object replaces the stored section wholesale (send the full style you want);
null resets it to the stock look. Only the keys you set are stored — anything omitted keeps
the design's default.
curl -X POST "https://app.tease.link/api/admin/bg/ui?domain=links.creator.com" \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"buttons": {
"shape": "pill",
"fill": "solid",
"opacity": 0.95,
"color": "#e0426e",
"text": "auto",
"animation": "breathe",
"anim_speed": "slow",
"font": "unbounded",
"align": "icon_left"
}
}'
# Back to the stock look
curl -X POST "https://app.tease.link/api/admin/bg/ui?domain=links.creator.com" \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "buttons": null }'| Key | Type | Description |
|---|---|---|
shape | string | rounded, pill, square, or sharp. |
fill | string | glass, solid, outline, soft_shadow, or hard_shadow. |
opacity | float | Button background opacity, 0.05–1. |
color | string | Button colour, #rrggbb. Empty string returns to the default. |
shadow_color | string | Shadow colour for the shadow fills, #rrggbb. |
text | string | Label mode: auto (picked against the button colour), dark, or light. |
animation | string | none, pulse, breathe, float, shake, glow, shine, wobble, or heartbeat. |
anim_speed | string | slow, normal, or fast. |
font | string | Label typeface: wix (default), gilroy, tiktok, satoshi, nunito, onest, clashdisplay, bebas, unbounded, prata, pacifico, or greatvibes — see Fonts. |
text_size | float | Label size multiplier, 0.7–1.5. |
text_color | string | Exact label colour, #rrggbb — an explicit hex wins over the text mode. Empty string = auto (light or dark picked against the surface). |
icon_color | string | Icon glyph colour, #rrggbb. Empty string returns to the stock white glyph. |
icon_bg | string | Colour of the circle behind the icon, #rrggbb, or none to remove the circle. Empty string returns to the stock black circle. |
align | string | center (icon + label centered as one group), icon_left (icon pinned to the left edge, label centered), or left (whole row flush left). |
fills | array of string | Up to 6 fill styles (same list as fill); with 2+ entries the button cycles through them instead of one flat fill. Under 2 entries collapses back to a plain fill. |
hover | string | Desktop pointer-hover effect: lift, none, ink, glow, shine, tilt, press, or border. |
new_tab | bool | Open every button's link in a new tab instead of navigating away — some ad networks require this. |
reveal_delay | float | Seconds before the whole button row fades in on load, 0–10 (0 = instant). For per-element scroll/delay/touch/exit triggers instead, see Appear rules. |
width | int | Fixed button width in px, 160–360. |
pad_y | int | Vertical padding in px, 6–28. |
See Style every button on the landing page for a full worked example, and Landing-page builder for the free-canvas layout, teaser stack, sticky bar, spotlight and appear-rules surface that lives alongside this section.
Fonts
The same twelve-face brand pack powers every typography knob on the page — button labels
(buttons.font), the profile card (name_font / bio_font), and the notification banner
(style.font):
| Id | Face | Notes |
|---|---|---|
wix | Wix Madefor Display | The brand default — what you get when no font is set. |
gilroy | Gilroy | Premium geometric grotesque in the OnlyFans style (the OnlyFans logo itself is custom lettering — this is the closest typeface look). |
tiktok | TikTok Sans | TikTok's actual brand typeface (released under the OFL). |
satoshi | Satoshi | Modern grotesque. Latin-only. |
nunito | Nunito | Rounded, friendly sans. |
onest | Onest | Clean geometric sans. |
clashdisplay | Clash Display | Fashion-forward display face. Latin-only. |
bebas | Bebas Neue | Tall condensed caps. Latin-only. |
unbounded | Unbounded | Wide crypto-modern display. |
prata | Prata | Elegant serif. |
pacifico | Pacifico | Bold brush script. |
greatvibes | Great Vibes | Formal calligraphic script. |
Latin-only faces (satoshi, clashdisplay, bebas) have no Cyrillic glyphs — Cyrillic text set
in them falls back to the brand default face automatically, so a Russian label never renders as
tofu. Font ids from the retired v1 pack keep working on read: they simply render as the default.
Changing font re-renders every button label of the domain in the new face on the server (labels
are pre-baked SVGs) — the response reports the count as relabeled. All faces are self-hosted
under free licenses and served same-origin from your domain (/fonts/lf_<id>.woff2), so the page
never makes a third-party font request.
Platform designs only
The style applies while the domain uses a built-in design. An uploaded custom-HTML design owns its own look and never inherits these settings — your tuning is kept though, so switching back to a platform design restores it.
Free plan badge
Landing pages of free-plan workspaces show a small "tease.link" badge under the buttons (on custom designs too). Upgrading removes it automatically — there is nothing to configure.
Profile card
The same bg/ui endpoint styles the profile card above the buttons — the nickname's typeface
and size, the "Active now • Responds in 5 mins" status line, and an optional bio with its own
typeface and size — via the second section, profile. Platform designs only, same draft → publish lifecycle, same replace-wholesale
semantics: send the full section you want, or { "profile": null } to reset to the stock card.
curl -X POST "https://app.tease.link/api/admin/bg/ui?domain=links.creator.com" \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"profile": {
"name_font": "unbounded",
"name_size": 1.2,
"status_text": "Online now",
"bio": "18+ • daily drops",
"bio_font": "nunito",
"bio_size": 0.9
}
}'| Key | Type | Description |
|---|---|---|
name_font | string | Nickname typeface — same font list as the button font. |
name_size | float | Nickname size multiplier, 0.7–1.6. |
status | bool | Whether the status line shows. false hides it; omitted = shown. |
status_text | string | Custom status-line text, up to 48 chars. Empty returns the stock wording. |
bio | string | A short bio under the nickname/status, up to 160 chars. Empty removes it. |
bio_font | string | Bio typeface — same font list. Omitted = the brand default. |
bio_size | float | Bio size multiplier, 0.7–1.6 (1 = stock). |
18+ content gate
The same bg/ui endpoint carries an optional 18+ sensitive-content gate — a confirmation
step shown before the page: either a full-screen cover (optionally blurring the page behind it)
or a compact sheet from the top edge. A visitor confirms once per browser and is never asked
again. Platform designs only, same draft → publish lifecycle.
curl -X POST "https://app.tease.link/api/admin/bg/ui?domain=links.creator.com" \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"gate": {
"enabled": true,
"mode": "cover",
"blur": true,
"title": "Adults only",
"text": "This page contains sensitive content.",
"ok": "I’m 18 or older",
"leave": "Leave",
"leave_url": "https://google.com"
}
}'| Key | Type | Description |
|---|---|---|
enabled | bool | Whether the gate shows. |
mode | string | cover — full-screen confirm; drop — a sheet from the top edge. |
title / text | string | Custom copy (64 / 300 chars). Omit for the stock wording. |
ok / leave | string | Button labels (32 chars each). |
leave_url | string | Where the leave button sends visitors who decline. Empty = a neutral default. |
blur | bool | Blur the page behind the cover-mode gate. |
Send { "gate": null } to remove the gate entirely.
Most creators don't need this
The gate is for human visitors — an extra layer of conservatism for strict traffic sources. Bots, scrapers, and in-app link scanners are already handled: cloaking keeps your content buttons invisible to them without bothering real fans.
Desktop-wide mode, free canvas, stack, sticky bar & appear rules
bg/ui carries several more sections beyond buttons/profile/gate — full walkthroughs live under
Landing-page builder, summarised here so the whole
bg/ui surface is in one place:
| Section | What it does | Full guide |
|---|---|---|
desktop | Full-monitor layout on wide screens instead of a phone-card. mode (phone/wide), align, width (280–560), dim (0–0.7), blur. | Enable full-screen desktop mode |
layout | mode: "free" switches the button column to a free-form canvas; pos places each button/object by id as {x, y, w, h}. | Place elements freely on a canvas |
objects | Extra canvas elements — items[] of type text/image/video/icon/shape/teaser/badge, up to 40. | Place elements freely on a canvas |
stack | A swipeable/pinned stack of up to 8 locked teaser cards. | Build a teaser card stack/carousel |
sticky | A bar that stays visible while scrolling, with its own CTA button style. | Add a sticky call-to-action bar |
appear | Per-element show rules — react to scroll/delay/touch/exit instead of showing instantly, plus a delayed background reveal. | Control when an element or background appears |
exit / timer | An exit-intent popup and/or an urgency countdown. | Add an exit-intent popup and/or an urgency timer |
Spotlight — a whole design, not a bg/ui section
POST /api/admin/bg/design {"design": "spotlight"} switches the page to a second built-in
design (alongside classic) that frames the hero photo fixed behind the buttons instead of a
scrolling column. Its own spotlight bg/ui section (blur 0–40, focus_x/focus_y 0–100,
zoom 100–300) tunes the photo's focal point, zoom and bottom blur. The free canvas
(layout.mode: "free" + objects) is not available on the Spotlight design.
Languages & auto-translate
Segments carry an optional lang target, and the notification banner carries a translations map,
so the public text can be auto-translated per locale. The translations object is keyed by language
code and only ever holds the public text fields:
{
"translations": {
"es": { "title": "Nuevo drop", "body": "Toca para ver el set de hoy", "link_text": "Abrir" },
"fr": { "title": "Nouveau drop", "link_text": "Ouvrir" }
}
}Supported codes include en, ru, pt, es, de, fr, it, nl, pl, tr, ar, hi,
id, ja, ko, zh. The call-to-action destination is never part of a translation — only the
visible title, body, and link_text.
QR codes
Every domain can hand out printable QR codes — a flyer, a sticker, a business card — that lead straight to the landing page and are tracked like any other traffic source, live outside the draft/publish cycle. See QR codes for creating, designing, downloading, reading a code's funnel, and renaming or disabling one without devaluing what's already printed.
Draft → preview → publish
Everything above writes to the draft. Nothing is live until you publish. The recommended loop:
Inspect the draft vs. published state
GET /api/admin/config returns both snapshots plus which domains are dirty:
curl https://app.tease.link/api/admin/config \
-H "Authorization: Bearer $TEASE_API_KEY"{
"is_dirty": true,
"dirty_domains": ["links.creator.com"],
"draft": { "updated_at": 1751280600, "domains": {}, "segments": {}, "notifications": {}, "backgrounds": {} },
"published": { "updated_at": 1751280000, "domains": {}, "segments": {}, "notifications": {}, "backgrounds": {} }
}| Field | Type | Description |
|---|---|---|
is_dirty | bool | Whether any domain's draft differs from its published page. |
dirty_domains | string[] | The hostnames with unpublished changes. |
draft / published | object | Per-domain matrices of buttons, segments, notifications, and backgrounds for each state. |
Preview a live audience
Request a signed preview token, then open the preview page with it. You can simulate the exact audience — country, device, OS, hour, and language — so you see what a US-mobile visitor at 9pm would see without changing anything live.
In the panel, the landing preview drives the same simulation through a single «Аудитория»
selector: pick a segment (each option shows its country flags and button count) and the preview
renders that audience. Under the hood the selector pins the segment via force_segment, so even a
countryless default catch-all previews exactly. It stays in two-way sync with the segment rail, and
the fine-grained country / OS / hour / language overrides live under «ещё…».
curl "https://app.tease.link/api/admin/preview-token?domain=links.creator.com&force_country=US&force_device=mobile" \
-H "Authorization: Bearer $TEASE_API_KEY"{
"token": "…",
"public_url": "https://links.creator.com",
"frame_path": "/preview-frame/"
}| Query | Type | Description |
|---|---|---|
domain | string | The domain to preview. |
force_country | string | Simulate a country (ISO alpha-2, upper-case). |
force_device | string | mobile or desktop. |
force_os | string | ios, android, windows, macos, or other. |
force_hour | int | Simulate the hour of day, 0–23. |
force_lang | string | Simulate a 2-letter language. |
force_segment | string | Render exactly this segment (by segment_id), skipping the geo classifier. Unknown ids are ignored; a block segment still previews its 403. |
The returned token is short-lived and authorizes only a preview of your own draft. Open
public_url joined with frame_path, carrying the token, to render the simulated page.
Owner-only
preview-token is restricted to the account owner.
Publish (or discard)
When the preview looks right, publish the draft to the live page. Discard throws the draft away and restores it from the published page — your single global undo.
Publish and discard
POST /api/admin/publish copies the draft to the live page atomically. With no body it publishes
every dirty domain; pass a domains array to publish only some.
# Publish everything that changed
curl -X POST https://app.tease.link/api/admin/publish \
-H "Authorization: Bearer $TEASE_API_KEY"
# Publish only one domain
curl -X POST https://app.tease.link/api/admin/publish \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "domains": ["links.creator.com"] }'await fetch('https://app.tease.link/api/admin/publish', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TEASE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ domains: ['links.creator.com'] }),
});requests.post(
"https://app.tease.link/api/admin/publish",
headers={"Authorization": f"Bearer {os.environ['TEASE_API_KEY']}"},
json={"domains": ["links.creator.com"]},
)| Field | Type | Required | Description |
|---|---|---|---|
domains | string[] | no | Hostnames to publish. Omit (or send no body) to publish all dirty domains. An explicit empty list is rejected. |
{ "ok": true, "bg_files_by_domain": {}, "domains": ["links.creator.com"] }Publishing with nothing changed returns 400 nothing_to_publish. POST /api/admin/discard takes
the same optional domains selection and restores the draft from published; with nothing to undo it
returns { "ok": true, "noop": true }.
discard is destructive to your draft edits — it replaces the draft with whatever is currently
live. Your published page is untouched.
Audit feed
Every change above is journaled. GET /api/admin/audit returns the most recent entries (newest
first), useful for an activity feed or change log.
curl "https://app.tease.link/api/admin/audit?limit=50" \
-H "Authorization: Bearer $TEASE_API_KEY"{
"items": [
{
"id": 9012,
"ts": 1751280600,
"actor": "owner",
"action": "publish",
"payload": "{\"tiers_updated\": true}"
}
]
}| Field | Type | Description |
|---|---|---|
limit | query | Number of entries to return, 1–500 (default 100). |
id | int | Monotonic entry id. |
ts | int | Unix timestamp. |
actor | string | Who made the change. |
action | string | The change type (e.g. segment_create, publish). |
payload | string | null | A JSON string with action-specific details. |
FAQ
Next steps
SDKs & tooling
Thin typed wrappers for Node and Python that centralize the base URL, bearer auth, retry/backoff, and pagination — plus the published OpenAPI spec for Postman, Insomnia, and code generators.
Cloaking
Per-domain protection that keeps your content buttons invisible to bots, scrapers, and in-app link scanners — so a real fan sees the funnel and a moderation crawler doesn't.