Admin endpoints
Every internal admin-panel route Link serves — path, method, what it does, owner-only or not.
This is a reference table of Link's internal admin-panel API — the cookie-authenticated
routes the panel itself calls, not the external public API (see API Reference
for TEASE's actual public, key-authenticated surface). "Owner-only" means the route's real
dependency chain requires strict workspace-owner identity (require_owner,
require_owner_or_home, or require_workspace_owner) — a team member with panel access still
gets a 403. Everything else is reachable by any authenticated admin/team session whose grant
covers that tab, gated only by require_admin and (for domain-scoped routes) the hub-scope fence
(see Boundaries).
Every row below was generated from the routes actually mounted by app.link_main.create_app()
(242 real routes; the 3 that are not admin-panel routes — health, the shared /api/config
preview endpoint, and the SPA fallback — are excluded here), with the owner-only column produced
by walking each route's real FastAPI dependency tree, not by reading names or docstrings. That
mechanical scan was then independently cross-checked against the actual handler code, file by
file, by 30 parallel reads — which caught one real drift worth knowing about: tg_turnstile.py's
own module docstring claims every route there guards on require_owner_or_home, but the code
was migrated to a newer require_workspace_owner guard on 2026-08-30 and the docstring was never
updated. The table below reflects the code, not the stale comment.
Identity (login, team access)
| Method | Path | Description | Owner-only |
|---|---|---|---|
| POST | /api/admin/auth/display-name | Requires an authenticated admin/creator/team-session principal (require_admin_principal), resolves that principal's own users row, validates the new name (rejects control characters, enforces 2–48 chars, 400 on invalid input or when no profile row exists), then writes display_name and commits, returning {ok: true, display_name}. | No |
| POST | /api/admin/auth/login | Enforces HTTPS, then checks the form against the env admin credentials (constant-time username compare + bcrypt password check); on mismatch it falls back to a team-member login (_try_team_login) matching users/team_member_access by username-or-email with per-IP/per-account lockout+throttle and a TOTP-challenge step; on success it issues either the env-admin admin_session cookie or a team user_session cookie (204), otherwise returns 401, with 429 once the per-IP failure limiter trips. | No |
| POST | /api/admin/auth/logout | No authentication dependency; unconditionally clears both the admin_session cookie and any lingering creator/team user_session cookie and returns 204, performing no database reads or writes. | No |
| POST | /api/admin/auth/timezone | Requires require_admin_principal, resolves the caller's own users row, validates the IANA timezone string (400 for an unknown zone or when no profile row exists), then writes timezone and commits, returning {ok: true, timezone}. | No |
| GET | /api/admin/auth/whoami | Requires require_admin_principal; reads any resolved team_access off request.state, computes the effective workspace owner/tier (tier_for) and engine-connected flag, derives all role/capability booleans via _whoami_capabilities, and reads the caller's own users row for email/timezone/display_name — a read-only lookup returning WhoamiOut (with a nested team block for team members). | No |
| GET | /api/admin/team/assignable-accounts | Returns models and pages the caller may assign to a team member: reads the workspace's OFAccount/Creator registry via _workspace_registry and, when called by a manager, narrows the list to rbac.assigned_pages (own scope) so a manager never sees accounts they can't grant; keeps archived models visible so a hidden assignment isn't mistaken for none. | No |
| GET | /api/admin/team/devices | Lists TeamMemberDevice rows for the workspace (optionally filtered by member_id), and for a manager caller filters the result to devices of subordinate (level-0) members via _subordinate_ids; no writes. | No |
| PATCH | /api/admin/team/devices/{device_id} | Updates a TeamMemberDevice row's status (approved/revoked, 422 on any other value) and/or label; 404s if the device isn't in the caller's workspace or (for a manager) isn't a subordinate's device, records approved_by/approved_at on status change, and writes an audit entry Team: device. | No |
| GET | /api/admin/team/members | Lists non-revoked TeamMemberAccess grants for the caller's workspace, and for a manager caller filters out other managers (keeps level-0 executors plus the manager's own row), building each row via _member_row (joins User, resolves effective role rights). | No |
| POST | /api/admin/team/members | Creates a login User row plus a TeamMemberAccess grant: validates username format/uniqueness (409 on collision) and password strength (422), resolves rights either from a role (role_id, rejecting a payload that also sends conflicting tabs/edit_tabs/caps/money_mode with role_locked, or an archived role with role_archived) or from raw tabs/caps (validated + rank-checked), validates the creator/account assignment against the workspace registry, enforces manager subordination (_manager_can_grant/_manager_can_assign) when the caller is a manager, optionally links or creates an Operator roster row, and writes audit entry Team: access granted. | No |
| PATCH | /api/admin/team/members/{member_id} | Patches a TeamMemberAccess grant's fields (label, tabs/edit_tabs/caps, money_mode, data_scope, sources/creators/accounts, role_id/clear_role, operator_id/clear_operator, status, shift settings, password, timezone, mobile_chat_allowed, device_gate): enforces optimistic-concurrency via _guard_concurrent_member_edit (412 on real conflict), blocks direct rights edits while a role is attached (role_locked), re-validates tabs/caps/assignment, checks manager subordination on the resulting grant, resets the password hash and bumps sessions_valid_from on password change, writes audit entry Team: access changed, and fires a sessions_revoke_all security event after a password reset. | No |
| DELETE | /api/admin/team/members/{member_id} | Revokes a TeamMemberAccess grant (status→revoked, 404 if not in workspace or not a manager's subordinate): frees the member's username, clears their password hash/salt, bumps sessions_valid_from to kill all live sessions, cascades revocation to the member's live ApiKey rows (and invalidates their auth cache entries), writes audit entry Team: access revoked, and fires a sessions_revoke_all security event after commit. | No |
| GET | /api/admin/team/roles | Lists TeamRole rows for the workspace, filterable by archived flag and a case-insensitive name substring q; read-only, readable by owner or any manager (no roles_manage cap needed). | No |
| POST | /api/admin/team/roles | Creates a TeamRole row after checking name uniqueness (409 duplicate_role) and validating tabs/edit_tabs/caps/shift fields, enforcing manager subordination via _manager_can_grant when the caller is a manager, and writes audit entry Team: role created; gated by _require_roles_editor, which for a manager additionally requires the team/roles_manage capability (403 otherwise) — owner is never blocked. | No |
| PATCH | /api/admin/team/roles/{role_id} | Patches a TeamRole's name/tabs/edit_tabs/caps/money_mode/archived/shift fields (live rights shared by every holder), re-checks name uniqueness and manager subordination on the resulting role, and writes audit entry Team: role changed; gated by _require_roles_editor (owner, or manager with team/roles_manage cap). | No |
| DELETE | /api/admin/team/roles/{role_id} | Deletes a TeamRole row only if zero live (non-revoked) TeamMemberAccess grants currently hold it, else 409 role_in_use naming the holder count; writes audit entry Team: role deleted; gated by _require_roles_editor (owner, or manager with team/roles_manage cap). | No |
Domains
| Method | Path | Description | Owner-only |
|---|---|---|---|
| POST | /api/admin/bulk/copy-content | Requires require_admin; validates the source domain exists (404 source_not_found) and each target exists and differs from source (400 copy_same_domain, 404 domain_not_found); if notification or bg is among the requested parts, requires the source to already have that config (400 otherwise); for each target copies the selected draft-state parts (buttons_segments, bg, notification, canvas_color) from source, writes a bulk_copy_content audit row and commits per target, and on a per-target failure rolls back, purges any partially-written bg files, and records {ok:false, detail:...} in the response body instead of raising. | No |
| GET | /api/admin/domain-health | Read-only file read of <data_path>/domain_health_latest.json written every ~2min by a systemd timer; returns an honest-empty {ts: null, stale: true, domains: []} when the file is missing/unreadable or its timestamp is invalid, otherwise groups checks per domain and flags stale: true once the verdict is older than 600s, and for a scoped (non-unrestricted) principal filters both domains and the healed list down to hostnames of Domain rows owned by that principal via rbac.resolve_data_scope. | No |
| GET | /api/admin/domain-reputation | Reads the reputation-scan cache file via latest_verdicts() (no DB write), returns an honest-empty/stale payload when the file is missing or its timestamp is unusable, otherwise projects each verdict to a panel-safe subset, filters to the caller's own domains via RBAC data-scope when not unrestricted, computes a worst-first summary, and reports whether the Google Safe Browsing check is actually enabled. | No |
| GET | /api/admin/domains | Lists Domain rows scoped to the caller's creator (or all domains for the platform owner), intersects with the request's team-source scope if set, and attaches each row's draft Button count via a grouped count query. | No |
| POST | /api/admin/domains | Requires require_admin; validates the hostname (rejects a www. prefix with 400 domain_www_mirror), returns the existing row idempotently if the same owner re-posted the same hostname within 60s, otherwise 409s (domain_taken) on a collision; gates on tier (_assert_can_own_domains), the owner's domain cap, and claim eligibility (_assert_can_claim_host); inserts the Domain plus default draft/published Notification, BgConfig, DomainVisibility and Segment rows, writes a domain_create audit row, commits (409 domain_taken on a race via IntegrityError), and schedules async infrastructure provisioning as a background task. | No |
| GET | /api/admin/domains/buy | Read-only; requires require_admin; returns the caller's owner-scoped domain purchases and statuses via purchase.list_purchases — no writes. | No |
| POST | /api/admin/domains/buy/order | Requires require_admin; gates on tier via _assert_can_own_domains, then on the buy_domain_enabled settings flag (503 buy_domain_disabled if off) and a per-owner cap of 10 open quoted orders (429 too_many_open_orders); calls purchase.create_order to create a NOWPayments invoice and persist a quoted PurchasedDomain row (409 domain_unavailable if purchase.PurchaseError is raised), then writes a domain_buy_order audit entry and commits. | No |
| POST | /api/admin/domains/buy/search | Read-only; requires require_admin; calls purchase.search_and_quote to return availability plus registrar-cost-plus-markup pricing across the requested (or default) TLDs from the registrar stub client — no DB writes. | No |
| POST | /api/admin/domains/connect | Enforces the paid-tier domain-ownership gate and a same-zone ownership guard, auto-detects the DNS provider if not supplied, delegates to connect_service.connect_domain to create/update the Domain row (passing a stub Cloudflare seam for zones we control so no real custom-hostname is registered), optionally applies display_name/source_icon (validating .svg icons via icon_visible, 400 source_icon_not_found), for an our-zone domain calls Cloudflare's API (ensure_proxied_record) to activate SSL immediately, auto-provisions the domain's focus-card smartlink when it goes active, writes an audit log entry domain_connect, commits, and resets the host-owner cache on auto-provision. | No |
| GET | /api/admin/domains/detect-provider | Pure read-only lookup: runs a best-effort DNS/NS-based provider detection (dns_detect.detect_provider) and checks Cloudflare zone ownership for the given hostname, performing no DB writes. | No |
| GET | /api/admin/domains/link-ops/enabled | Read-only: reads engine-readiness flags from a file via promo_ops.ops_enabled(owner_id) (no DB access despite the session dependency, which exists solely to gate this behind full team-permission resolution) and returns both an aggregate of_enabled and a per-operation ops breakdown for the FE to show "preparing" before attempting a write. | No |
| GET | /api/admin/domains/links/all | Loads every Domain hostname (entity-scoped query so the tenant auto-filter narrows an owner-scoped caller), optionally narrows to the caller's team-source-scoped hosts, then for each host merges _links_payload (SegmentLink + OfCampaign rows) into one cross-domain list and returns it with trial/tracking/stamped counts; pure read, no writes, no OFAPI calls. | No |
| GET | /api/admin/domains/suggest | Read-only: takes a nick query string and calls suggest_branded(nick, _RdapChecker()) to propose an available branded domain via a best-effort DNS/RDAP availability check; no DB writes. | No |
| POST | /api/admin/domains/{domain_id}/verify | Loads the Domain by id and 404s (not 403, deliberately, to avoid an ownership oracle) if it doesn't belong to the caller's team scope, runs connect_service.verify_domain (a stdlib-DNS + Cloudflare status probe) to advance the domain from pending to active, and on success auto-provisions the focus-card smartlink, writes an audit log entry domain_verified, and commits. | No |
| PATCH | /api/admin/domains/{domain}/campaigns/{provider_id}/hidden | Validates the domain exists (404 domain_not_found) and the OfCampaign row exists for domain+provider_id (404 campaign_not_found), then sets OfCampaign.hidden via apply_campaign_hidden, snapshotting the campaign's current lifetime revenue when hiding so new money later resurfaces it; writes a domain_campaign_hidden audit entry. Purely local — no OFAPI call, no change to source/code/attribution. | No |
| PATCH | /api/admin/domains/{domain}/campaigns/{provider_id}/rename | Looks up the OfCampaign row for domain+provider_id (404 campaign_not_found if missing), overwrites its local display_name, writes a domain_campaign_rename audit entry, then calls _promo_submit to queue an OF-side rename op before committing and invalidating the links cache. | No |
| POST | /api/admin/domains/{domain}/cloak | Looks up the Domain row by hostname (404 domain_not_found if missing), sets its cloak_bots flag from the request body, writes a domain_cloak_toggle audit entry, commits, then invalidates the links read cache so the public /r route (60s TTL) picks up the new bot-cloak behavior. | No |
| POST | /api/admin/domains/{domain}/direct-redirect | Looks up the Domain row by hostname (404 if missing), sets direct_redirect (and, unless turning it off — which resets ab_pct to 0 — an optional ab_pct 0-100), writes a domain_direct_redirect_toggle audit entry, commits, then force-clears an in-process module-level cache on app.api.traffic_serve (best-effort, swallowed on failure) plus the links read cache so the public route flips immediately instead of waiting out its 60s TTL. | No |
| GET | /api/admin/domains/{domain}/links | Validates the domain exists (404 domain_not_found), then returns _links_payload for it — SegmentLink+OfCampaign rows with per-link revenue computed over the optional period_from/period_to unix range (0/0 = all-time) and show_hidden filter. Read-only. | No |
| POST | /api/admin/domains/{domain}/links | Validates the domain exists (404 if not), then under a per-domain provision lock calls ensure_static_links (real OFAPI create-or-adopt-by-name call for one country×arm slot, idempotent — reuses an existing url'd row), flushes, calls stamp_static_links, writes a domain_add_link audit entry, commits, invalidates the links read cache, and calls export_both; returns 502 link_not_permanent on a StaticLinkPermanenceError, 502 provision_failed on any other exception (with rollback), or 502 link_unresolved if the slot still has no url after provisioning. | No |
| PATCH | /api/admin/domains/{domain}/links/{link_id} | Flips one SegmentLink.active flag for domain+link_id (404 if not this domain's; 409 link_archived if trying to re-activate a row whose OF link was already deleted), then re-stamps the domain's button arms via stamp_static_links (502 stamp_failed on error, with rollback) and calls export_both; writes a domain_set_link_active audit entry. Makes no OFAPI calls itself. | No |
| DELETE | /api/admin/domains/{domain}/links/{link_id} | Deletes/retires one SegmentLink row for domain+link_id (404 if not this domain's) in one of three modes selected by query flags: local=true hard-deletes only OUR routing row with no provider call; default soft mode just sets active=0 keeping the OFAPI link and row; hard=true first 409s (url_stamped_elsewhere) if the link's url is still stamped on another domain's published button, then calls the OFAPI client's delete_trial_link/delete_tracking_link (502 provider_delete_failed on failure, with rollback) before removing the row; any row removal re-homes an orphaned OfCampaign twin onto host, then re-stamps the domain's buttons (stamp_static_links, 502 stamp_failed on error) and calls export_both, writing a domain_delete_link audit entry with the outcome. | No |
| POST | /api/admin/domains/{domain}/links/{link_id}/delete-on-of | Validates the domain and loads the SegmentLink row for that domain+id (404 via _get_link_for_domain if not found); raises 409 url_stamped_elsewhere if the link's URL is stamped on another domain's published button, or 409 no_provider_id if the row has no OnlyFans-side id to delete; otherwise enqueues a delete op to the promo engine queue and commits — the row itself is only marked archived later, by a reconciler, once the engine confirms the deletion. | No |
| PATCH | /api/admin/domains/{domain}/links/{link_id}/rename | Fetches the domain's SegmentLink row via _get_link_for_domain (404 if not this domain's), overwrites its local display_name, writes a domain_link_rename audit entry, then (only if the row has a provider_id) calls _promo_submit to queue an OF-side rename before committing and invalidating the links cache. | No |
| GET | /api/admin/domains/{domain}/links/{link_id}/spenders | Read-only: validates the domain and loads the SegmentLink row (404 if not this domain's link), computes our own DB-derived lifetime totals and per-fan spender rows (_our_calc_by_provider, joining of_transactions/fan_link by provider_id), and separately best-effort fetches OFAPI's own headline via an external OnlyFansAPI client call (list_trial_links/list_tracking_links) that never raises — any lookup failure or no-match just logs a warning and returns ofapi: null — returning both numbers side by side without summing them; no writes or audit log. | No |
| POST | /api/admin/domains/{domain}/provision-links | Validates the domain exists (404 domain_not_found), builds a slot plan from the request config, then under a per-domain lock calls ensure_static_links which creates real trial/tracking links on onlyfansapi.com and registers/reuses matching SegmentLink rows (502 link_not_permanent on a StaticLinkPermanenceError, 502 provision_failed on any other failure, both with rollback), flushes, re-stamps the domain's buttons, writes a domain_provision_links audit entry with created/adopted/reused counts, commits, invalidates the links cache, and calls export_both. | No |
| POST | /api/admin/domains/{domain}/provision-links/preview | Validates the domain exists (404 domain_not_found), builds the same slot plan provision-links would use, and diffs it purely in the DB against the domain's existing SegmentLink rows to report per-slot new/exists status and would-create/already-exist counts; makes no OFAPI calls and writes nothing. | No |
| GET | /api/admin/domains/{domain}/smartlink/{code}/funnel | Validates the {domain} path segment via validate_domain_ref, then delegates to smartlink_funnel(session, code) which reads clicks, of_conversions, and of_transactions to build a read-only clicks→subs→spenders conversion funnel (with recurring revenue) for the one smartlink identified by code; no writes. | No |
| POST | /api/admin/domains/{domain}/source-link | Validates the domain exists (404 domain_not_found) and name is non-empty (422 name_required), then calls create_source_links which queues one or more per-domain source links through the live OF gateway's create_smart_link (paced, async) and writes PENDING SegmentLink rows for a reconciler to fill (ValueError from the core mapped to 400 source_link_invalid; 409 slot_exists if every requested target was already occupied by a live slot and nothing got queued); writes a domain_source_link_create audit entry, commits, invalidates the links cache. | No |
| PATCH | /api/admin/domains/{hostname} | Validates the hostname, loads the Domain row (404 if missing), optionally updates display_name and source_icon (validating an .svg icon exists via icon_visible, raising 400 source_icon_not_found otherwise, and deriving source_platform from the icon), writes an audit log entry domain_update, and commits. | No |
| DELETE | /api/admin/domains/{hostname} | Requires require_admin; takes a durable per-hostname mutation lock, loads and row-locks the Domain (404 domain_not_found), asserts via domain_safety that deletion is safe and no migration is in flight, deletes the domain's related rows plus the Domain row itself, writes a domain_delete audit row and commits, then best-effort deletes its Cloudflare custom hostname, orphaned button labels, and notification/background files/dirs, resets several in-memory caches, and — if the domain had managed infra — calls deprovision_domain and writes a second domain_deprovision audit row. | No |
| POST | /api/admin/domains/{hostname}/copy-bg-from | Requires require_admin; resolves the {hostname}/source pair (400 copy_same_domain, 404 if either domain is missing), copies the source's draft BgConfig to the target via _copy_bg_draft, clears the target's applied_from_asset_id, writes a domain_copy_bg audit row, commits, and re-exports draft state. | No |
| POST | /api/admin/domains/{hostname}/copy-from | Requires require_admin; resolves the {hostname}/source pair (same 400/404 checks), deletes the target's existing owner-scoped draft Button and Segment rows and replaces them with copies of the source's rows, regenerating each button's label image file, writes a domain_copy_buttons audit row with the copied counts, commits, cleans up the target's now-orphaned old label files, and re-exports draft state. | No |
| POST | /api/admin/domains/{hostname}/copy-notification-from | Requires require_admin; resolves the {hostname}/source pair, requires the source to have a draft Notification row (400 source_notification_missing otherwise), copies its fields plus avatar/sound asset files on disk to the target, upserts the target's draft Notification row (forcing enabled=0 if no avatar was copied), writes a domain_copy_notification audit row, commits, deletes the target's now-unreferenced old avatar/sound files, and re-exports draft state. | No |
| POST | /api/admin/domains/{hostname}/rename | Renames/moves a Domain's public address: locks domain refs, parses the target as host or path-slug, 404s if the domain is missing, 400s on a no-op rename, 409s if an active DomainMigration touches either address, and dispatches to one of _move_back_to_own_host, _move_to_path_address, _apply_rename, or _move_to_reserve depending on whether the target is free, owned by the caller, or owned by another workspace (409 domain_taken/domain_in_use in the latter cases). | No |
| POST | /api/admin/domains/{hostname}/retry-provisioning | Requires require_admin; takes a per-hostname mutation lock, loads the Domain (404 domain_not_found if missing), synchronously re-runs provision_domain for its infrastructure, writes a domain_provision_retry audit row with the resulting state/error, commits, and returns the refreshed domain. | No |
| GET | /api/admin/domains/{host}/visibility | Resolves {host} to a registered Domain (raising 404 domain_not_found if none matches), fetches (or lazily creates, seeded with live-default values) the domain's draft DomainVisibility row, commits the session to persist any newly-created row, and returns it as VisibilityRead; guarded only by require_admin, no audit write. | No |
| PATCH | /api/admin/domains/{host}/visibility | Resolves {host} (404 domain_not_found if unregistered), loads/creates the draft DomainVisibility row, then applies only the non-null fields of the patch: merges escape network toggles onto the current map after rejecting any name not in VISIBILITY_NETWORKS (400 unknown_network), validates bot_filter_level against VISIBILITY_BOT_LEVELS (400 bad_bot_level), and sets traffic_split_enabled/dc_cloak_enabled/escape_fullscreen directly; if any field actually changed it bumps updated_at, calls write_audit with action visibility_patch (domain + changed field names), commits, and calls export_draft to re-bake runtime_draft.json; if nothing changed it returns early with no audit/commit/export. Guarded only by require_admin. | No |
Domain links (inventory on a domain)
| Method | Path | Description | Owner-only |
|---|---|---|---|
| GET | /api/admin/links/availability | Read-only: delegates to slug_service.slug_availability(session, host, slug) to report whether the (host, slug) pair is claimable, returning a free-slug suggestion when it is already taken; no writes. | No |
| POST | /api/admin/links/claim | If source_icon is a .svg reference, validates it's visible to the caller via icon_visible (400 source_icon_not_found otherwise), then calls slug_service.claim_slug to claim (host, slug) for current_owner_id(), optionally attaching content_host/display_name/source_icon so the claimed link becomes a manageable source, returning the created record with HTTP 201. | No |
| POST | /api/admin/links/cost | Validates the LinkCostBody (including cost_model against LINK_AD_SPEND_MODELS), calls upsert_link_cost to insert/update the ad-spend row for (link_key, platform, period_start), writes an audit-log entry (link_cost_set) via write_audit, commits the session, and invalidates the links read-cache before returning {ok, link_key, cost_model, amount_cents, rate_cents}. | No |
| POST | /api/admin/links/provision | Validates the domain exists (404 domain_not_found) and arm is paid/ftl (400 bad_arm), then idempotently ensures one routing-slot SegmentLink row exists for (domain, segment_id, arm, country) — reusing it (reused: true) if already present, otherwise inserting a new row (a ftl slot forced no-stack via offer_expiration=0/offer_limit=0) and writing a links_provision_slot audit entry; never calls OnlyFans/OFAPI since the /r relay resolves the offer server-side. | No |
| GET | /api/admin/links/shared-hosts | Read-only: returns {hosts: [...]} from the global shared_link_domains setting (get_settings().shared_link_domains_list) — global config, not per-tenant data, gated only by require_admin + team_fence. | No |
| GET | /api/admin/links/{ref}/icon | Validates {ref} against the 3 link types (404 link_type_unknown otherwise) and returns the caller's current icon descriptor for that type — {kind:'custom', value, svg} if an owner-scoped LinkIcon row exists, else {kind:'brand'} — a pure read with no writes. | No |
| PUT | /api/admin/links/{ref}/icon | Validates {ref}, runs the submitted SVG through sanitize_svg (strips scripts/handlers/external refs, size-capped at 1MB; raises 400 link_icon_unsafe on failure), then upserts a LinkIcon row scoped to the caller and type, writes an audit-log entry (link_icon_set or link_icon_update), invalidates the links read-cache so other endpoints don't serve a stale pre-icon row for up to the cache TTL, and returns the new icon descriptor. | No |
| DELETE | /api/admin/links/{ref}/icon | Validates {ref} is one of ftl/tracking/smart (404 link_type_unknown otherwise), deletes the caller's own owner-scoped LinkIcon row for that type if one exists (idempotent no-op otherwise so a foreign creator's row is never touched), writes an audit-log entry link_icon_delete, invalidates the links read-cache (invalidate_links_cache), and returns the reverted brand-kind icon descriptor. | No |
| GET | /api/admin/links/{ref}/icon.svg | Validates {ref}, looks up the caller's owner-scoped LinkIcon row for that type and streams its raw SVG body back as image/svg+xml with Cache-Control: no-cache (404 link_icon_not_found if no built icon exists for the type); read-only. | No |
Smart links & ads
| Method | Path | Description | Owner-only |
|---|---|---|---|
| GET | /api/admin/bg/ad-campaigns | Resolves the domain via _resolve_domain and reads that domain's AdCampaign rows, returning the base ad link (?source=ig_ad) plus each campaign's slugged link; read-only. | No |
| POST | /api/admin/bg/ad-campaigns | Resolves the domain, derives a slug from the given name (400 campaign_name_invalid if the slug comes out empty), rejects a duplicate slug on that domain (409 campaign_exists), otherwise inserts a new AdCampaign row, writes an ad_campaign_create audit entry, commits, and returns the campaign's ad link. | No |
| DELETE | /api/admin/bg/ad-campaigns/{camp_id} | Looks up the AdCampaign by id (404 not_found if missing), deletes it, writes an ad_campaign_delete audit entry, and commits. | No |
| GET | /api/admin/bg/ad-platforms | With domain given, resolves it via _resolve_domain, seeds a default Meta row for it, then reads AdPlatformConnection rows for that domain only; without domain, seeds quick-connect rows for every domain and reads ALL connections account-wide — either way also returns the owner's hostnames (for the Add-form picker) and the ad-network registry/event-map choices; read-only aside from the seeding inserts. | No |
| POST | /api/admin/bg/ad-platforms | Parses a Content-Type-agnostic JSON body, resolves the target domain from query or body, validates the platform against the ad-network registry (400 platform_unsupported), validates the pixel id format per network (400 pixel_invalid), requires an access token if the network mandates one (400 capi_token_required) and validates any required extra fields (400 extra_required), then inserts a new AdPlatformConnection row, writes an ad_platform_create audit entry, commits, and mirrors the active Meta/browser pixel onto BgConfig for both draft and published states, re-exporting the landing bundle. | No |
| GET | /api/admin/bg/ad-platforms/logs | Read-only: queries CapiPostbackLog filtered by optional domain/platform, returning the 50 most recent server-to-ad-network delivery events plus grouped counts of sent/failed/skipped status for the same filter. | No |
| PUT | /api/admin/bg/ad-platforms/{conn_id} | Looks up the AdPlatformConnection by id with no tenant filter beyond the id itself (404 not_found if missing), then patches whichever of name/pixel_id (re-validated per network)/access_token (a blank value is ignored so it never wipes the stored secret)/event_source_url/event_map/extra (400 extra_required if a required extra field is missing)/status/send_all_conversions are present in the body, writes an ad_platform_update audit entry, commits, and re-syncs the domain's browser pixel in BgConfig. | No |
| DELETE | /api/admin/bg/ad-platforms/{conn_id} | Looks up the AdPlatformConnection by id (404 not_found if missing), deletes the row, writes an ad_platform_delete audit entry, commits, and re-syncs the domain's browser pixel (turns it off if no active connection remains). | No |
| POST | /api/admin/campaigns/{provider_id}/delete-on-of | Resolves the campaign by provider_id under the caller's scope; if its URL is stamped on a published button of another domain, raises 409 url_stamped_elsewhere instead of deleting; otherwise enqueues a delete op to the internal promo-engine job queue (_promo_submit, which itself writes its own audit record) and commits — it does NOT locally mark the row deleted/inactive, that happens later via a reconciler once the engine confirms the OnlyFans-side result. | No |
| PATCH | /api/admin/campaigns/{provider_id}/rename | Resolves the campaign by provider_id under the caller's scope, updates ONLY its local display_name, writes a domain_campaign_rename audit entry, commits, invalidates the links read cache, and best-effort enqueues a rename op to the promo engine queue purely for bookkeeping — the response's of_pushed is always false because OnlyFans has no rename endpoint for either trial or tracking links. | No |
| GET | /api/admin/smart-links | Reads all OfSmartLink rows for the current owner (via smart_links_list_data), joins OfConversion rows through the trial-attribution view and overrides each link's revenue with forward-link (of_transactions) money when the link resolves to a forward channel, windows the conversion rollup by the optional period query param (24h/7d/30d/90d/all, raising 400 invalid_period for an unknown token), and masks any pending own-engine row's real provider_id/redirect_url behind provider_id=""+status="pending"; read-only, no writes. | No |
| POST | /api/admin/smart-links | Creates (or reuses an inactive slot for) an OfSmartLink row for offer in {paid, ftl} (400 invalid_offer otherwise; 400 invalid_trial_days if ftl's free_trial_days isn't a valid TRIAL_DURATIONS value), enforces at most one active link per offer via a pre-check plus an IntegrityError-to-409 fallback for a concurrent create (409 smart_link_exists), builds redirect_url from the owner's own smartlink/domain/segment-link inventory (400 no_trial_inventory if an ftl link has no trial URL of its own), writes an audit log entry smart_link_create, and commits. | No |
| GET | /api/admin/smart-links/{provider_id} | Looks up one OfSmartLink by provider_id (404 smart_link_not_found if missing under the current owner), reads its OfConversion rows through the trial-attribution view windowed by the optional period query param (400 invalid_period for an unknown token), aggregates a top-200-by-revenue fan breakdown and a by-conversion-type breakdown, and overrides each fan's total with forward-link (of_transactions) revenue when the link resolves to a forward channel; read-only, no writes. | No |
| PATCH | /api/admin/smart-links/{provider_id} | Renames only the local name display label of an OfSmartLink (no upstream OnlyFans/provider call, since offer/trial-days are immutable there); 400 invalid_name for an empty/whitespace name, 404 smart_link_not_found if the row doesn't exist under the current owner, writes an audit log entry smart_link_rename, and commits. | No |
| DELETE | /api/admin/smart-links/{provider_id} | Soft-retires an OfSmartLink by setting active=0 (404 smart_link_not_found if it doesn't exist under the current owner) rather than hard-deleting, since OfLinkStat/OfConversion history keys off provider_id; cascades to deactivate every currently-active SmartLinkPixel bound to it (one-directional, not auto-resumed on re-enable), writes an audit log entry smart_link_delete including the pixels-paused count, commits, and returns 204. | No |
| GET | /api/admin/smart-links/{provider_id}/conversions | Lists raw OfConversion rows for one smart link straight from the DB (no live provider call), passed through the trial-attribution view, windowed by the optional period query param and filtered by optional conversion_type, sorted newest-first on the parsed conversion timestamp, and paginated via limit/offset (limit 1-500); 404 smart_link_not_found if the link doesn't exist under the current owner, 400 invalid_period for an unknown period token; read-only. | No |
| GET | /api/admin/smart-links/{provider_id}/pixels | Lists SmartLinkPixel config rows for a smart link (404 smart_link_not_found if the link doesn't exist under the current owner); access_token is never included in the response, only a derived has_token boolean; read-only. | No |
| PUT | /api/admin/smart-links/{provider_id}/pixels | Creates or updates the SmartLinkPixel row keyed by (provider_id, platform): validates the smart link exists (404 smart_link_not_found), platform is meta/tiktok (400 invalid_platform), pixel_id matches ^[A-Za-z0-9_-]{1,64}$ (400 invalid_pixel_id), and non-empty event_map_json parses as a JSON object of string→string (400 invalid_event_map); requires access_token on create (400 token_required) but keeps the existing token when a blank value is sent on update; writes an audit log entry pixel_upsert that never includes the token, commits, and returns the saved row without access_token. | No |
| DELETE | /api/admin/smart-links/{provider_id}/pixels/{platform} | Hard-deletes the SmartLinkPixel row for (provider_id, platform): 404 smart_link_not_found if the smart link itself doesn't exist under the current owner, 404 pixel_not_found if no matching pixel row exists; writes an audit log entry pixel_delete, commits, and returns 204. | No |
| POST | /api/admin/smart-links/{provider_id}/toggle | Sets an OfSmartLink's active flag to the body's active value (404 smart_link_not_found if the row doesn't exist under the current owner); when disabling, also deactivates every currently-active SmartLinkPixel bound to it (pixels are not auto-resumed on re-enable), writes an audit log entry smart_link_toggle including the pixels-paused count, and commits. | No |
| GET | /api/admin/smartlinks | Lists Smartlink rows ordered by id, optionally filtered by exact domain query param, then filters the result in Python to the caller's team_source_scope domains before returning them as SmartlinkList. | No |
| POST | /api/admin/smartlinks | Validates the body's domain via validate_domain_ref/require_host_in_scope, 404s if the Domain row doesn't exist, derives or validates a slug (rejecting reserved/duplicate slugs with 400), validates the policy JSON (400 on error), inserts a new Smartlink row, writes an audit log entry (smartlink_create), commits (turning a race-condition IntegrityError into a 400 slug_taken), and invalidates the links read cache. | No |
| GET | /api/admin/smartlinks/ad-performance | Reads click/conversion data scoped to owner_id (from tenant.current_owner_id()) and the given domain, and computes the Meta-ad funnel (ad visits tagged source=ig_ad → reached OF → subscribers → net revenue, plus geo and pixel/CAPI status) via compute_ad_performance, returning the result through cached_call keyed on (owner, domain, period_from_bucket, period_to); no request-body validation beyond query defaults, no writes, and no explicit HTTP error raised in this handler itself. | No |
| GET | /api/admin/smartlinks/breakdown | Calls _require_link_scope to 403 a domain-scoped team member addressing a link outside their scope, then returns a read-only geo/source breakdown (breakdown()) of clicks/revenue for one link keyed by keys/codes/arm/host. | No |
| GET | /api/admin/smartlinks/fan-transactions | Calls _require_link_scope, then returns fan_transactions() — a merged, newest-first history combining one fan's real of_transactions purchases and their conversions for the given link, each row tagged with source; read-only. | No |
| GET | /api/admin/smartlinks/fans | Calls _require_link_scope to 403 a domain-scoped team member addressing another domain's link, parses keys into conversion keys, and returns {fans: [...]} from smartlink_fans (name/handle, subscribed, net revenue, net tips, recurring) for that smartlink; read-only. | No |
| GET | /api/admin/smartlinks/fans-detail | Calls _require_link_scope, then returns {fans: [...]} from link_fans() — a per-fan table (name, avatar, subscribed, duration, is_stacker, revenue) for the link's Performance "Фаны" tab, optionally filtered by search/stackers; read-only. | No |
| GET | /api/admin/smartlinks/funnel | Calls _require_link_scope(request, session, keys, codes), which raises 403 (scope_denied) if a team member whose access is restricted to specific domains requests a keys/codes combination belonging to a link outside their domain scope, then reads per-link click/conversion rows via funnel_for (built from a _link_ref descriptor) to return the per-link conversion funnel honoring since/period_from/period_to/cumulative; purely a read, no writes. | No |
| GET | /api/admin/smartlinks/link-index | Read-only endpoint that resolves the caller's scope (single page / creator rollup / tenant rollup / whole account) via _scope and rbac.resolve_data_scope (forged/unauthorized ids fail closed to an empty set), computes a per-link index through cached calls to compute_link_index/compute_link_index_rolled, then, for a domain-scoped team member, filters the smart/tracking/trials rows via filter_source_rows and rebuilds the reconcile totals from only the visible rows. | No |
| GET | /api/admin/smartlinks/performance | Calls _require_link_scope on codes/click_codes (403 scope_denied if a domain-scoped team member addresses a foreign-domain link), and when scope=page with an id is supplied resolves the request principal and checks _scope(session, principal, "page", id).ok, raising 403 scope_denied if that page isn't authorized for the caller before pinning the compute's owner to that page; it then builds the full performance card (overview, funnel, trend, geo, cohort/LTV, transactions, fans) by calling compute_link_performance under tenant.owner_scope(owner) — cross-referencing compute_source_leaderboard's fan set when income_ref is set so the card matches the income drill's number — and returns the result via cached_call; read-only, no writes. | No |
| GET | /api/admin/smartlinks/timeseries | Calls _require_link_scope, validates tz via is_valid_timezone (silently falling back to UTC on invalid input rather than erroring), then returns zero-filled daily/weekly buckets of clicks/subs/revenue/CR% (optionally cumulative) from timeseries(); read-only. | No |
| GET | /api/admin/smartlinks/transactions | Calls _require_link_scope, then returns a paginated (page/page_size), newest-first, of_conversions-based feed of conversions attributed to one link via link_transactions(); read-only. | No |
| GET | /api/admin/smartlinks/vitrina | Read-only endpoint that resolves the account/creator/tenant/page scope via _report_scope, returns cached {smart, static} link lists (SMART via _vitrina_scoped/list_smartlinks anchored to the creator, STATIC via list_static per selected page), then further row-filters both lists for a domain-scoped team member via filter_source_rows. | No |
| PATCH | /api/admin/smartlinks/{smartlink_id} | Loads the Smartlink by id scoped to the caller's domain (404 if out of scope), updates name/policy/active fields when present (validating policy JSON, 400 on error), and when deactivating a smartlink still bound to published buttons and confirm isn't set, raises 409 deactivate_confirm_required; writes an audit log entry (smartlink_update), commits, and invalidates the links cache. | No |
| GET | /api/admin/smartlinks/{smartlink_id}/geo-map | For a smartlink scoped to the caller (404 if out of scope), builds a per-country {arm, destination} table by running the same RouteResolver used in production against either the caller-supplied countries query list or (default) the countries of the segments whose buttons target this smartlink in the given state, flagging countries with zero resolvable destinations as dead. | No |
| GET | /api/admin/smartlinks/{smartlink_id}/inventory | For a smartlink scoped to the caller (404 if out of scope), reads all SegmentLink rows with smartlink_id equal to this smartlink (excluding trial-split clone rows), returning each as an editable inventory item plus a split_targets map of (arm,country) keys that currently have more than one active live target. | No |
| POST | /api/admin/smartlinks/{smartlink_id}/inventory | For a smartlink scoped to the caller (404 if out of scope), validates country as empty or a 2-letter code (400 country_invalid) and url via validate_url; if a non-split SegmentLink already exists for this smartlink+arm+country it upserts its url/name/active in place, otherwise it 409s (inventory_slot_taken) if another active row already occupies that domain+arm+country slot, else inserts a new SegmentLink with segment_id='_smartlink' (turning a concurrent unique-constraint race into the same 409), writes an audit log entry (smartlink_link_add), commits, and invalidates the links cache. | No |
| PATCH | /api/admin/smartlinks/{smartlink_id}/inventory/{link_id} | For a smartlink+link scoped to the caller (404 if either out of scope or the link isn't owned by this smartlink), updates the SegmentLink's url (validated non-empty via validate_url), name/display_name, and/or active; when deactivating a non-split link it also force-deactivates any active split-clone rows sharing the same domain/arm/country so a hidden clone can't silently keep serving traffic, writes an audit log entry (smartlink_link_update), commits, and invalidates the links cache. | No |
| GET | /api/admin/smartlinks/{smartlink_id}/resolve | For a smartlink scoped to the caller (404 if out of scope), validates the optional arm query param against the allowed ARMS set (400 bad_arm otherwise), then for each comma-separated country code runs the production RouteResolver (no subnet stickiness) for each policy-eligible arm and returns one ResolvePreview row per (country, eligible arm) showing the destination code/url/layer that /r/{slug} would actually serve. | No |
| GET | /api/admin/smartlinks/{smartlink_id}/routing | For a smartlink scoped to the caller (404 if out of scope), builds the full routing read-model (_routing_health): per-country paid/ftl link status and A/B split derived from SegmentLink rows plus the effective policy weights, the shared default-link status, and an enriched issue list (each with a plain-language why/impact and a ready fix_call string) that also folds in the production _detect_issues results such as dead routes and owner-default leaks. | No |
| GET | /api/admin/smartlinks/{smartlink_id}/usage | For a smartlink scoped to the caller (404 if out of scope), is a read-only lookup that finds the Button rows in the given state whose URL path targets this smartlink's /r/{slug} (or a pinned /r/{slug}/{arm}), joins in their Segment rows to report each segment's exclusive (first-match) and full declared country lists, and returns the button list plus segment map — no writes. | No |
| GET | /api/admin/smartlinks/{smartlink_id}/validate | For a smartlink scoped to the caller (404 if out of scope), is a thin wrapper that calls _detect_issues (the production-resolver-based detector) and returns its ok/blocking/warnings/issues payload — no writes. | No |
Landing builder — buttons & profile
| Method | Path | Description | Owner-only |
|---|---|---|---|
| POST | /api/admin/bg/canvas-color | Validates color against CANVAS_COLOR_RE (#rrggbb, else 400 canvas_color_invalid), upserts the draft BgConfig.canvas_color, short-circuits with changed:false if unchanged, otherwise writes a bg_canvas_color audit log, commits, and calls export_draft. | No |
| POST | /api/admin/bg/design | Requires require_admin; resolves the target domain and validates design against allowed_designs() (400 if unknown); for design=custom it requires a valid custom_design_id, loads the LandingDesign row (404 if missing), enforces creator/model scope (403 on mismatch) and validates the design's slots via validate_landing_slots (422 if not ready); then upserts the domain's draft BgConfig row, writes an audit log entry (bg_design), commits, and re-exports the draft landing config. | No |
| GET | /api/admin/bg/draft/exists | Resolves and validates domain/variant query params (400 if domain missing, 404 if domain not registered), then checks whether any file matching bg-*.jpg exists in that domain/variant's draft-variants directory and returns {exists, variant, domain}; read-only, no DB writes. | No |
| GET | /api/admin/bg/draft/{filename} | Rejects filename containing /, \, or .. with 400 (path-traversal guard), resolves/validates domain and variant (400/404 via _resolve_domain), then streams the matching file from the draft-variants directory via FileResponse or returns 404 if it does not exist; read-only, no DB writes. | No |
| POST | /api/admin/bg/edit | Validates domain/variant and requires body {designState: {...}} (400 bad_payload otherwise), requires a previously uploaded source image (400 no_source), applies the design state to it via apply_design_state (400 bg_apply_failed on failure), wipes old draft variant files and regenerates them via process_background (500 bg_process_failed on failure), upserts the draft BgConfig row's source path/version/updated_at, touches app state, writes an audit-log entry bg_edit_commit, commits, and triggers export_draft. | No |
| POST | /api/admin/bg/meta-pixel | Requires require_admin; resolves the domain, validates pixel_id is 6–20 digits or empty (400 otherwise), and — only if the value changed — updates the draft BgConfig.meta_pixel_id, writes an audit log entry (bg_meta_pixel), commits, and re-exports the draft. | No |
| GET | /api/admin/bg/mode | Reads the draft BgConfig row for the resolved domain, parses bg_variants_json, batch-loads referenced BgAsset/MediaAsset rows, and annotates each variant with a drop reason via arm_drop_reason/media_arm_drop_reason/video_arm_drop_reason before returning mode, variants, dropped_count and measurement/carousel settings; read-only, no writes. | No |
| POST | /api/admin/bg/mode | Validates mode is one of single/random/ab/carousel (400 bad_mode), normalizes the variants list, and for ab/carousel requires at least 2 enabled variants (400 ab_needs_two/carousel_needs_two), then upserts the draft BgConfig row (mode, variants, random_show_n, measure/autoweight/carousel fields), writes a bg_mode_set audit log, commits, and regenerates the draft export via export_draft. | No |
| POST | /api/admin/bg/object-photo | Reads an uploaded file, detects image vs video (looks_like_video), enforces per-type size caps (413 object_photo_too_large), transcodes it via teaser_media.process_teaser_video/process_teaser_image (400 object_photo_invalid on failure), atomically writes the result(s) to notification_assets_dir(host), writes an object_photo audit log, and returns {kind, filename, url}-shaped data (plus poster/poster_url for video) — no DB row is created. | No |
| POST | /api/admin/bg/profile-name | Validates the name payload against MAX_PROFILE_NAME_LEN (400 profile_name_too_long) and rejects control characters via _PROFILE_NAME_CTRL_RE (400 profile_name_invalid), upserts draft BgConfig.profile_name, writes a bg_profile_name audit log, commits, and calls export_draft. | No |
| POST | /api/admin/bg/promote | Requires require_admin; resolves the domain and validates bg_asset_id is numeric or local/empty (400 otherwise); for a real asset id it loads a visibility-scoped BgAsset (404 if not found or out of scope) and copies its rendered variant files on disk into the domain's draft variants directory (400 if none exist), bumping the draft BgConfig version for mobile/desktop and recording applied_from_asset_id; it always resets the draft config to single-arm mode (clearing bg_variants_json/random_show_n), writes an audit log entry (bg_promote_winner), commits, and re-exports the draft. | No |
| GET | /api/admin/bg/source | Resolves/validates domain and variant, picks the domain/variant's source image file via _pick_source, returns a plain 404 Response if none exists, otherwise streams the raw source file via FileResponse; read-only, no DB writes. | No |
| GET | /api/admin/bg/source/exists | Resolves/validates domain and variant, checks whether a source image exists for that domain/variant via _pick_source, and returns {exists, variant, domain}; read-only, no DB writes. | No |
| POST | /api/admin/bg/stack-photo | Same upload/transcode/size-cap pipeline as upload_object_photo (image or video into notification_assets_dir(host), 413 stack_photo_too_large/stack_video_too_large, 400 stack_photo_invalid), but logs a bg_stack_photo audit event and returns the file under a file key instead of filename; no DB row is created. | No |
| POST | /api/admin/bg/tracking | Requires require_admin; resolves the domain, validates pixel_id format (400 if invalid), and writes meta_pixel_id/meta_capi_token to BOTH the draft and published BgConfig rows (clearing the token when the pixel is cleared), writes an audit log entry (bg_tracking, recording only whether a token was set, not its value), commits, and re-exports both the published and draft configs. | No |
| POST | /api/admin/bg/ui | Merges the request body into draft BgConfig.ui_json via landing_ui.merge_ui_patch (400 landing_ui_invalid on LandingUiError); if the button font changed, regenerates label images for that domain's draft Button rows in a thread pool, updates Button.text_filename, and cleans up orphaned label files via _cleanup_orphan_label; writes a bg_landing_ui audit log and calls export_draft. | No |
| POST | /api/admin/bg/upload | Validates domain/variant, reads the uploaded file enforcing content-type in JPEG/PNG/WebP/AVIF (400 bg_type), a max size (413 bg_too_large) and non-empty body (400 bg_empty), deletes sibling source files for that variant, atomically writes the new source image to disk, upserts the draft BgConfig row with the new relative source path and clears applied_from_asset_id, touches app state, writes an audit-log entry bg_source_upload, and commits. | No |
| GET | /api/admin/bg/variants/stats | Requires require_admin; resolves the domain and a days window (1–365), reads the published BgConfig's resolved arms, and aggregates Impression counts plus Click/OfConversion rows (excluding non-landing buttons) within the cutoff to compute per-arm impressions, clicks, CTR, subscriptions and revenue — a read-only report with no writes. | No |
| POST | /api/admin/bg/video | Reads an uploaded file up to MAX_BG_VIDEO_BYTES+1, rejects non-video content via looks_like_video (400 bg_video_invalid), transcodes it with teaser_media.process_bg_video (400 bg_video_invalid on failure), atomically writes video+poster to the domain's draft video dir, bumps draft BgConfig.bg_video_source_path/bg_video_version, writes a bg_video_upload audit log, and calls export_draft. | No |
| DELETE | /api/admin/bg/video | Clears draft BgConfig.bg_video_source_path, deletes the domain's draft video and poster files from disk (missing files ignored), writes a bg_video_delete audit log, commits, and calls export_draft. | No |
| GET | /api/admin/bg/video/file | Serves the domain's background video or poster (which=video|poster) as a FileResponse with Cache-Control: no-store, checking the draft dir then the published static dir, and raises 404 bg_video_missing if neither exists; read-only. | No |
| POST | /api/admin/buttons/{domain}/bulk | Validates the domain and each segment_id exist, validates the icon, match/variants JSON and popup/sensitive/media/style payloads via landing_ui (raising 400 button_invalid on validation failure), then for each segment inserts a draft Button row (raising 409 button_id_taken if an explicit button_id collides), generates a label image file per row, and writes a button_create audit entry per segment; on any exception it rolls back and deletes any label files already generated before re-raising, and on success calls export_draft to regenerate the export. | No |
| GET | /api/admin/buttons/{domain}/{tier} | Read-only: after validating the domain and segment (tier) exist, returns all draft-state Button rows for that domain+tier ordered by position; makes no writes. | No |
| POST | /api/admin/buttons/{domain}/{tier} | Creates a single draft Button row in the given domain/segment after validating domain, segment, and icon exist and validating match/variants JSON and popup/sensitive/media/style (each raising its own 400 error code on landing-UI validation failure), enforces button_id uniqueness (409 button_id_taken), generates a label image file, writes a button_create audit entry, commits, and calls export_draft. | No |
| PUT | /api/admin/buttons/{domain}/{tier}/order | Reorders all draft buttons in a domain/tier to match the given id list, rejecting duplicate ids (400 order_duplicate) or an id set that doesn't exactly match the tier's current buttons (400 order_mismatch), then sets each button's position, writes a button_reorder audit entry, commits, calls export_draft, and returns the buttons in their new order. | No |
| PATCH | /api/admin/buttons/{domain}/{tier}/{button_id} | Partially updates an existing draft Button (404 button_not_found if missing), applying only fields present in the payload with per-field re-validation (icon existence, match/variants JSON, popup/sensitive/media/style each with its own 400 error code), redacts the url in the audit diff as ***, regenerates and swaps the label image file when text or effective font changed (cleaning up the old orphaned label), and only touches state / writes a button_update audit entry / commits / calls export_draft if something actually changed. | No |
| DELETE | /api/admin/buttons/{domain}/{tier}/{button_id} | Deletes an existing draft Button row (404 button_not_found if missing), re-sequences the remaining buttons' position values to stay contiguous, writes a button_delete audit entry, commits, then removes the deleted button's now-orphaned label file and calls export_draft. | No |
| POST | /api/admin/buttons/{domain}/{tier}/{button_id}/duplicate | Duplicates an existing draft Button in place (404 button_not_found if source missing): shifts the position of every later button down by one, inserts a copy with a freshly generated unique button_id and a newly generated label image, writes a button_duplicate audit entry recording before/after button-id ordering, and on any exception rolls back and deletes the generated label file before re-raising; on success calls export_draft. | No |
| POST | /api/admin/buttons/{domain}/{tier}/{button_id}/popup-photo | For an existing draft button (404 button_not_found if missing), reads up to MAX_POPUP_PHOTO_BYTES+1 bytes from the uploaded file, validates/processes it via process_popup_photo (400 popup_photo_invalid on failure), writes it into the domain's notification-assets directory using a temp-file-then-rename, writes a button_popup_photo audit entry, commits, and returns {filename, url} pointing at /notification/{domain}/{filename}. | No |
| POST | /api/admin/buttons/{domain}/{tier}/{button_id}/teaser | For an existing draft button (404 button_not_found if missing), reads up to MAX_TEASER_VIDEO_BYTES+1 bytes and branches on looks_like_video: video is transcoded plus a poster frame generated via process_teaser_video and optionally pushed to Bunny CDN storage (marking a local .bunny sentinel file); image is size-checked against MAX_TEASER_IMAGE_BYTES and processed via process_teaser_image (400 teaser_invalid on any processing failure); writes the resulting file(s) to the domain's notification-assets directory, writes a button_teaser audit entry, commits, and returns the kind/filename/URL(s). | No |
| GET | /api/admin/notification/{domain} | Resolves {domain} to a registered hostname (404 domain_not_found if unknown), fetches the draft Notification row for that domain or auto-creates an empty one via _get_or_create_draft (committing so the new row persists), and returns it serialized as NotificationRead. | No |
| PATCH | /api/admin/notification/{domain} | Resolves {domain}, loads/creates the draft Notification row, and applies any provided subset of enabled/title/body/link_text/target_url/link_mode/delay_ms/translations/assignment/style — validating translations shape, validating each enabled A/B preset (404 preset_not_found, 400 preset_incomplete if texts/link/avatar missing) and the style payload via landing_ui.validate_notif_style (400 notification_style_invalid), enforcing that enabling the banner requires title/body/link_text/avatar all set and, in manual link mode, a target_url (400 notification_incomplete otherwise); on any real change it writes an audit log (notification_patch, content masked), commits, materializes any assigned preset's avatar/sound files into the domain's asset dir, and calls export_draft to rebuild the exported bundle. | No |
| POST | /api/admin/notification/{domain}/avatar | Resolves {domain}, rejects non-image content types (400 avatar_type), reads up to MAX_AVATAR_BYTES+1 and rejects oversize uploads (413 avatar_too_large), validates/processes the image via process_avatar (400 avatar_invalid on failure), updates the draft row's avatar_filename, writes an audit log (notification_avatar_upload with filename+size), commits, atomically writes the processed file to data/notification/<domain>/, deletes the previous avatar file unless it's still referenced by the published row, and calls export_draft. | No |
| DELETE | /api/admin/notification/{domain}/avatar | Resolves {domain}, loads/creates the draft row, and (no-op if already avatar-less and disabled) clears avatar_filename and force-sets enabled=0 to prevent an invalid enabled-without-avatar state, writes an audit log (notification_avatar_delete), commits, then unlinks the old avatar file from disk unless the same filename is still referenced by the domain's published row, and calls export_draft. | No |
| GET | /api/admin/notification/{domain}/report | Resolves {domain}, computes first-party impression/click counts and CTR from the Impression/Click tables over a trailing days-day window (clicks filtered to button_id == "__angel_notify__"), and, when the domain's published (falling back to draft) notification has 2+ enabled A/B presets, re-derives a per-arm shown/clicked/CTR breakdown by replaying each row's stored subnet through the same deterministic runtime.choose_variant weighting used live; read-only, no writes. | No |
| POST | /api/admin/notification/{domain}/sound | Resolves {domain}, reads up to MAX_SOUND_BYTES+1 and rejects oversize uploads (413 sound_too_large), validates/processes the audio via process_sound (400 sound_invalid on failure), updates the draft row's sound_filename, writes an audit log (notification_sound_upload with filename+size), commits, atomically writes the file to data/notification/<domain>/, deletes the previous sound file unless still referenced by the published row, and calls export_draft. | No |
| DELETE | /api/admin/notification/{domain}/sound | Resolves {domain}, loads/creates the draft row, no-ops if sound_filename is already null, else clears it, writes an audit log (notification_sound_delete), commits, unlinks the old sound file from disk unless still referenced by the published row, and calls export_draft. | No |
Landing builder — reusable library
| Method | Path | Description | Owner-only |
|---|---|---|---|
| GET | /api/admin/library/backgrounds | Lists bg_asset rows for the caller's account (owner_id auto-filter plus the creator_id scope via _scope_visible), ordered newest-first, with optional limit/offset pagination and a total count of all matching rows; no writes. | No |
| POST | /api/admin/library/backgrounds | Creates a new bg_asset row: validates variant_kind is one of mobile/desktop/both (400 bad_variant_kind otherwise), resolves the creator_id to stamp via _resolve_create_creator_id (403 creator_scope_denied if a confined caller requests another model's scope), writes an audit entry library_background_create, then commits. | No |
| PATCH | /api/admin/library/backgrounds/{asset_id} | Partially updates a scoped bg_asset row's name/variant_kind/source_path/edited_path/canvas_color_hint and optionally bumps derivative_version, re-validating variant_kind (400 bad_variant_kind), writes audit entry library_background_update, then commits. | No |
| DELETE | /api/admin/library/backgrounds/{asset_id} | Deletes one bg_asset row scoped to the caller (404 bg_asset_not_found if missing/out of scope), writes audit entry library_background_delete, commits, then best-effort purges the asset's on-disk source/derivative files from disk (_purge_bg_asset_files, never raises on I/O failure). | No |
| POST | /api/admin/library/backgrounds/{asset_id}/apply | Stamps a scoped bg_asset as a single-arm variant into each target domain's DRAFT BgConfig row (bg_mode='single', bg_variants_json set to a one-item list, and canvas_color copied from the asset's hint if present); resolves/validates target domains via _resolve_domains (404 domain_not_found on any unregistered host, 400 no_domains if the list is empty), writes audit entry library_background_apply, commits, then re-bakes runtime_draft.json via export_draft. | No |
| POST | /api/admin/library/backgrounds/{asset_id}/edit | Applies an editor crop/design-state ({designState: {...}}, 400 bad_payload if malformed) to the asset's previously-uploaded source image (400 no_source if none), regenerates the public derivative files via apply_design_state/process_background (400 bg_process_failed on a BackgroundProcessingError), bumps derivative_version to cache-bust the funnel, writes audit entry library_background_edit, commits, then re-bakes drafts via export_draft. | No |
| GET | /api/admin/library/backgrounds/{asset_id}/source | Streams back the scoped asset's un-edited source image file for the given variant (or a bare 404 Response if no source was uploaded for that variant) so the editor can re-open it; read-only, no DB writes. | No |
| POST | /api/admin/library/backgrounds/{asset_id}/upload | Uploads and stores a new source image for a scoped bg_asset per variant: validates content-type against an allowed image set (400 bg_type), rejects empty (400 bg_empty) or oversized files over 25MB (413 bg_too_large), writes the file to asset_data_dir, clears the asset's edited_path (invalidating any prior crop), writes audit entry library_background_upload, then commits. | No |
| GET | /api/admin/library/blocks | Lists CanvasBlock rows for the caller's tenant (owner_id auto-filtered) further scoped by _scope_visible to the principal's own creator_id(s) plus shared (creator_id='') blocks, ordered by created_at/id descending; no writes, no error paths beyond normal auth. | No |
| POST | /api/admin/library/blocks | Validates the posted canvas object via the landing-page validator (landing_ui.validate_objects, raising 400 block_invalid on failure or an empty block), enforces a per-tenant BLOCKS_MAX cap via a tenant-scoped count aggregate (400 blocks_limit if reached), resolves/authorizes the target creator_id (403 creator_scope_denied if a confined principal requests another model's scope), inserts a new CanvasBlock row, writes an audit log entry (library_block_create), and commits. | No |
| DELETE | /api/admin/library/blocks/{block_id} | Looks up a CanvasBlock by id within the caller's tenant/creator scope (404 block_not_found if absent or outside scope), deletes the row, writes an audit log entry (library_block_delete), and commits. | No |
| GET | /api/admin/library/button-sets | Lists button_set rows for the caller's account (owner_id auto-filter), further restricted by _scope_visible to creator_ids the principal may read (shared '' plus the principal's own model(s), or unrestricted if the principal is the owner/tenant-wide admin), ordered newest-first; raises no errors beyond the shared auth dependency. | No |
| POST | /api/admin/library/button-sets | Creates a new button_set row: 400 name_required if name is blank after stripping, resolves the stamped creator_id via _resolve_create_creator_id (403 creator_scope_denied if a scope-confined principal requests a model outside their own), serializes payload to payload_json, writes audit event library_button_set_create, then commits. | No |
| PATCH | /api/admin/library/button-sets/{set_id} | Looks up the scoped button_set row (404 button_set_not_found), optionally renames it (400 name_required if the new name is blank) and/or wholesale-replaces payload_json from the request body, bumps updated_at only if a field actually changed, writes audit event library_button_set_update, then commits. | No |
| DELETE | /api/admin/library/button-sets/{set_id} | Looks up the button_set row scoped to the caller (404 button_set_not_found if missing or out of the principal's creator scope), hard-deletes it, writes audit event library_button_set_delete, then commits; no cascading cleanup of domains that previously applied this set. | No |
| POST | /api/admin/library/button-sets/{set_id}/apply | Replaces a target segment's DRAFT Button rows on each named domain with the button set's stored buttons: 400 segment_required if segment_id is blank, 400 empty_button_set if the set's payload has no buttons list, _resolve_domains gives 400 no_domains/404 domain_not_found for bad hosts, and each domain gets 404 segment_not_found if that segment doesn't already exist in DRAFT there; deletes the segment's current DRAFT buttons and re-inserts the set's buttons, writes audit event library_button_set_apply, commits, then re-bakes runtime_draft.json via export_draft. | No |
| GET | /api/admin/library/media | Lists MediaAsset rows scoped by the owner auto-filter plus the caller's creator_id visibility (_scope_visible), ordered newest-first; no writes, no error paths beyond standard auth. | No |
| POST | /api/admin/library/media | Reads an uploaded file (rejecting with 413 if it exceeds MAX_TEASER_VIDEO_BYTES), detects image vs video via looks_like_video, transcodes it (process_teaser_image/process_teaser_video, raising 400 media_invalid on failure), inserts a MediaAsset row stamped with the resolved creator_id, writes a library_media_upload audit entry, commits, then writes the processed file(s) plus poster and a raw HD source copy into notification/_media_<id>/ on disk. | No |
| DELETE | /api/admin/library/media/{asset_id} | Looks up the MediaAsset scoped to the caller (404 media_asset_not_found if absent/out of scope), deletes the row, writes a library_media_delete audit entry, commits, then best-effort rmtrees the asset's notification/_media_<id>/ folder on disk. | No |
| POST | /api/admin/library/media/{asset_id}/materialize | Validates a required ?domain= against the Domain table (400 if missing, 404 domain_not_found if unregistered), loads the scoped MediaAsset, copies its stored file (and poster, for video) byte-for-byte into that domain's notification/<host>/ assets folder (404 media_file_missing if the source file is gone), writes a library_media_materialize audit entry, commits, and returns the filename/url/poster for the caller to wire into a teaser/stack/canvas config field — no re-transcode, no export_draft call. | No |
| POST | /api/admin/library/media/{asset_id}/use-as-bg-video | Validates a required ?domain= against the Domain table (400/404 domain_not_found), loads the scoped MediaAsset and rejects non-video assets (400 media_not_video), re-transcodes the asset's stored HD source (or 480p fallback) through process_bg_video with the given trim/soft-edge/quality params (400 bg_video_invalid on failure), writes the resulting video+poster into the domain's draft video dir, upserts/bumps the domain's BgConfig draft row, calls touch_state(STATE_DRAFT), writes a bg_video_from_media audit entry, commits, and calls export_draft to rebake the draft output. | No |
| GET | /api/admin/library/notifications | Reads NotificationPreset rows for the caller's account, filtered by the per-model creator_id scope (_scope_visible/creator_scope_for_principal) and ordered newest-first, with no writes or error branches beyond the shared auth dependency. | No |
| POST | /api/admin/library/notifications | Validates a non-empty, per-account-unique name (400 name_required, 409 preset_name_taken), resolves the creator_id to stamp via _resolve_create_creator_id (403 creator_scope_denied if a confined caller targets another model), inserts a new NotificationPreset row, writes an audit log entry (library_notification_create), and commits. | No |
| POST | /api/admin/library/notifications/from-domain | Validates the target domain exists (404 domain_not_found) and the domain's DRAFT Notification row has some filled-in text (400 notification_empty), enforces a unique preset name (400/409), creates a new NotificationPreset snapshotting that draft row's fields, stamps preset_id back onto the domain's draft row, writes an audit entry (library_notification_from_domain), commits, then copies the domain's avatar/sound files into the preset's master folder on disk. | No |
| PATCH | /api/admin/library/notifications/{preset_id} | Looks up the preset (404), applies any provided fields including a uniqueness-checked name change (400/409), and — if anything changed — re-stamps every DRAFT Notification row currently bound to this preset via _restamp_from_preset (which also flips enabled based on renderability); writes an audit entry (library_notification_update), commits, then materializes preset files into each synced domain and calls export_draft if any domain was synced. | No |
| DELETE | /api/admin/library/notifications/{preset_id} | Looks up the preset by scoped id (404 preset_not_found), nulls the preset_id binding on every Notification row (draft or published) that referenced it while leaving that row's stamped content intact, deletes the NotificationPreset row, writes an audit entry (library_notification_delete), and commits. | No |
| POST | /api/admin/library/notifications/{preset_id}/apply | Looks up the preset (404) and validates the target domains list (404 domain_not_found on the first unregistered host, via _resolve_domains), then stamps the preset's content plus a single-arm assignment_json ({v:1, items:[{preset_id, weight:1, enabled:true}]}) onto each target domain's DRAFT Notification row, setting enabled only when _preset_complete; writes an audit entry (library_notification_apply), commits, materializes the preset's avatar/sound files into each domain folder, and calls export_draft to rebake runtime_draft.json. | No |
| POST | /api/admin/library/notifications/{preset_id}/avatar | Looks up the preset (404), rejects a non-image content_type (400 avatar_type) and a payload over MAX_AVATAR_BYTES (413 avatar_too_large), runs process_avatar on the bytes and surfaces its failures as 400 avatar_invalid, sets avatar_filename, re-stamps every DRAFT Notification row bound to the preset, writes an audit entry (library_notification_avatar, including file size), commits, then atomically writes the new master avatar file to disk (unlinking the superseded one) and materializes it into synced domains, calling export_draft if any domain was synced. | No |
| DELETE | /api/admin/library/notifications/{preset_id}/avatar | Looks up the preset (404); if it already has no avatar, returns 204 as a no-op, otherwise clears avatar_filename, re-stamps every DRAFT Notification row bound to the preset (which can flip enabled off if the preset stops being renderable), writes an audit entry (library_notification_avatar_delete), commits, deletes the master avatar file from disk, and calls export_draft if any domain was synced. | No |
Landing builder — design templates
| Method | Path | Description | Owner-only |
|---|---|---|---|
| GET | /api/admin/landing-designs | Queries LandingDesign rows through _scope_visible (owner auto-filter + per-creator confinement), ordered by created_at desc then id desc, and returns a light LandingDesignRow list (no html_draft) with no DB writes. | No |
| POST | /api/admin/landing-designs | Validates name is non-empty and ≤64 chars (400 name_required/name_too_long), resolves the target creator_id via _resolve_create_creator_id, inserts a new LandingDesign row with empty draft html and status=draft/version=0, writes an audit log entry landing_design_create, commits, and returns the full row. | No |
| GET | /api/admin/landing-designs/starter.zip | Reads the four real starter-kit files (index.html, angel.css, README.md, PROMPT.md) off disk under core/landing/starter/, zips them in memory, and streams the result as application/zip with a Content-Disposition: attachment header and Cache-Control: no-store; touches no database table. | No |
| GET | /api/admin/landing-designs/{design_id} | Looks up the design via the owner+creator scope filter (404 design_not_found if it doesn't exist or belongs to another creator/owner) and returns the full row including html_draft. | No |
| DELETE | /api/admin/landing-designs/{design_id} | Looks up the owner+creator-scoped design (404 design_not_found if missing/foreign), refuses with 409 design_in_use if any BgConfig row for the current owner still references its custom_design_id (listing the offending domains), otherwise deletes the LandingDesign row, writes audit log landing_design_delete, commits, then removes its staged (landing_data_dir) and published (landing_static_dir) asset directories from disk. | No |
| GET | /api/admin/landing-designs/{design_id}/preview | Looks up the scoped design (404 if missing), returns a plain 404 if html_draft is blank, otherwise injects a <script src="/runtime.js?v={version}"> loader before </body> and serves the draft html with the same CSP used for live landers plus Cache-Control: no-store; performs no DB write. | No |
| POST | /api/admin/landing-designs/{design_id}/rename | Looks up the scoped design (404 if missing), validates the new name (400 name_required/name_too_long for empty or >64 chars), updates name/updated_at when changed, writes audit log landing_design_rename, commits, and returns the full row. | No |
| POST | /api/admin/landing-designs/{design_id}/upload | Rate-limits per actor (429 if exceeded), loads the scoped design (404 if missing), reads the body capped at 5MB (413/400 on too-large/empty), branches on zip vs raw html — a zip is extracted with a zip-slip guard, a ≤50-file and 5MB-uncompressed cap, an asset-extension allowlist, and SVG re-sanitization via sanitize_svg, while raw html is capped at 512KB — then always runs sanitize_landing_html (422 sanitize_failed on hard rejection) and validate_landing_slots, stores the cleaned html into html_draft/warnings_json regardless of slot-validity, writes audit log landing_design_upload, commits, writes any zip assets to landing_data_dir only after the commit, and responds {id, ok, warnings, errors} with 200 if slots are valid or 422 if not. | No |
Landing builder — lifecycle (publish/discard)
| Method | Path | Description | Owner-only |
|---|---|---|---|
| GET | /api/admin/audit | Read-only: returns the most recent limit (1-500, default 100) AuditEntry rows ordered newest-first, including actor, action and the raw payload JSON. | No |
| GET | /api/admin/config | Read-only: reads the published and draft scoped states and returns the FULL FullConfig (both snapshots plus is_dirty/dirty_domains/dirty_sections/draft_revision) — the heavy payload that /api/admin/config/dirty exists specifically to let most screens avoid. | No |
| GET | /api/admin/config/dirty | Computes the same published-vs-draft scoped_states/dirty_sections pair as /api/admin/config but returns only the compact verdict (is_dirty, dirty_domains, dirty_sections, draft_revision) instead of the full snapshots, so no DB writes occur and the response stays hundreds of bytes instead of the ~470KB /config payload. | No |
| POST | /api/admin/discard | Resolves and team-scopes an optional domain selection (404 domain_not_found for an unknown host) and optionally checks expected_revision (409 draft_revision_conflict); if nothing is dirty returns {ok:true, noop:true}, otherwise copies PUBLISHED back over DRAFT for Button, ConfigState, Notification, Segment, BgConfig and DomainVisibility rows (scoped to the selection), reverts any affected LandingDesign draft HTML to its published version, deletes on-disk edited/draft background-image variants and the draft video directory for the affected domains, writes a discard/discard_domain audit entry, commits, and calls export_draft(session). | No |
| POST | /api/admin/publish | Resolves/team-scopes an optional domain selection (404 domain_not_found) and optional expected_revision check (409 draft_revision_conflict); 400s with nothing_to_publish if nothing is dirty in scope, and 400s with smartlink_dead_route if any selected domain has unresolved dead smartlink routes; otherwise copies DRAFT over PUBLISHED for Button, ConfigState, Notification, Segment, BgConfig and DomainVisibility, promotes referenced LandingDesign draft HTML (and its static assets) to published, promotes draft background image/video files to the static/published tree and offloads or retires the background video on Bunny CDN (bunny_storage.offload_bg_video/retire_bg_video), writes publish/publish_domain audit entries (plus failure entries for any bg/video promote error), commits, and calls export_both(session). | No |
| GET | /api/admin/render-label | Renders an SVG label preview for the text query param (1-64 chars) via render_label() into a temp file and returns it as image/svg+xml with Cache-Control: no-store, raising 500 label_gen_failed on a LabelGenerationError; no database writes. | No |
Landing builder — buyer link
| Method | Path | Description | Owner-only |
|---|---|---|---|
| GET | /api/admin/landing-share | Validates the domain query param via validate_domain_ref (400 domain_invalid on malformed input), then selects all PdfExportShare rows with kind=="landing" (auto-scoped to the caller's tenant by the session-level ORM filter), filters in Python to non-revoked, non-expired rows whose stored config_json.domain matches, and returns them as LandingShareOut items (no DB writes). | No |
| POST | /api/admin/landing-share | Validates domain (400 domain_invalid), purges expired shares via cleanup_expired, computes a frozen stats snapshot for the domain via build_landing_view (reuses the same _compute_stats as revenue), creates a new PdfExportShare row (kind landing, random token + render_key, expires_days*86400 TTL or 0=never), writes the snapshot to <token>.view.json on disk, writes an audit log entry landing_share_create, commits, and returns the created share. | No |
| DELETE | /api/admin/landing-share/{share_id} | Looks up the PdfExportShare by id (404 share_not_found if missing or wrong kind), sets revoked=1 via an UPDATE (row is kept, not deleted, for audit purposes — the public share endpoint separately returns 404 once revoked), writes an audit log entry landing_share_revoke, commits, and returns 204. | No |
| POST | /api/admin/landing-share/{share_id}/refresh | Looks up the PdfExportShare by id (404 share_not_found if missing, revoked, or wrong kind), 400s as share_broken if its stored config has no domain, otherwise recomputes the stats snapshot for the same domain/day-window via build_landing_view, rewrites the <token>.view.json file and the row's config_json (same token/URL), writes an audit log entry landing_share_refresh, and commits. | No |
Icons
| Method | Path | Description | Owner-only |
|---|---|---|---|
| GET | /api/admin/icons | Read-only: returns the caller's own IconAsset rows plus the shared builtin icons (owner_id=="") via list_visible_icons(session), never another tenant's private upload. | No |
| POST | /api/admin/icons | Validates the uploaded file is SVG-typed and non-empty and under 1MB (400 icon_type/icon_empty, 413 icon_too_large), sanitizes the SVG (400 icon_unsafe on failure), inserts an owner-stamped IconAsset row and writes an icon_upload audit entry, commits, then atomically writes the cleaned SVG into icons_dir() and a best-effort durable copy under <data_path>/icons_master (survives static-tree rebuilds). | No |
| POST | /api/admin/icons/generate | Rate-limited to 6/min per actor (429 icon_gen_rate_limited); calls the external icon-AI provider (icon_ai.generate_icon_svg, Recraft v3 or LLM fallback) to turn prompt/style into an SVG, mapping a blocked daily quota to a quota-refusal error, disabled provider to 400 icon_ai_disabled, and other failures to 502 icon_ai_failed; on success inserts an owner-scoped IconAsset row, writes an icon_generate audit entry (including the prompt, truncated to 80 chars), commits, and writes the SVG to icons_dir() plus the durable icons_master copy. | No |
| DELETE | /api/admin/icons/{filename} | Rejects path-traversal filenames (400 filename_invalid); looks up the IconAsset under the tenant auto-filter so a builtin or another tenant's icon resolves to 404 icon_not_found; refuses to delete a builtin (400 icon_builtin) or an icon still referenced by any Button row, draft or published (409 icon_in_use); otherwise deletes the DB row, writes an icon_delete audit entry, commits, then best-effort unlinks the file from icons_dir() and the icons_master durable copy. | No |
| GET | /api/admin/preview-token | Validates optional domain against the Domain table (404 domain_not_found if it doesn't resolve), authorizes the caller via the module-local _require_preview_minter dependency (owner, the platform-home tenant, a tenant previewing their own domain, or a legacy ""-workspace team member scoped to that domain — else 403 owner_only), then calls issue_preview_token with preview-simulation overrides (force_country/device/os/hour/lang/bg_arm/segment) and binds owner_id into the token for owner/home minters so _enforce_domain_owner can later reject a cross-tenant override; no DB writes. | No |
Translation
| Method | Path | Description | Owner-only |
|---|---|---|---|
| POST | /api/admin/translate-domain/{domain} | Rate-limited (6/min per actor, else 429) handler that resolves {domain} (404 if unregistered), resolves the effective or inline locale config (400 bad_config on invalid inline config), computes the worst-case count of paid DeepSeek translation calls and reserves that against a per-owner daily quota (translate_domain ledger; all-or-nothing — insufficient quota raises the quota-refusal error with no partial work), then for each enabled language creates a missing DRAFT Segment plus translated Button rows (calling translate_text/DeepSeek per non-brand button, generating label SVGs) or, if force is set, deletes and recreates an existing language segment (unlinking old label SVG files), mapping TranslationDisabled/TranslationRateLimited/TranslationError to 503/503/502 respectively; optionally re-translates the Notification title/body/link_text into every draft-segment language (never target_url), validates the result via validate_notification_translations_json, then writes an audit log (languages/counts only, never text or URLs), commits, and calls export_draft to refresh the preview. | No |
| GET | /api/admin/translation-config | Read-only: fetches the owner's global LocaleConfig row (domain="") via a tenant-scoped query and returns the stored config (or the built-in default when unset) along with is_set, geo_modes, and source_tiers; no writes or side effects. | No |
| PUT | /api/admin/translation-config | Validates the request body as a locale config (raises 400 bad_config on failure), upserts the owner's global LocaleConfig row (domain=""), writes a locale_config_set audit entry, commits, and calls export_both to regenerate both draft and published exports immediately since geo_mode is baked into the published snapshot. | No |
| GET | /api/admin/translation-config/{domain} | Resolves {domain} (404 if unregistered) and returns, read-only, the domain's own override config (null means inheriting), the merged effective config (per-domain override -> owner global -> built-in default), the owner's raw global default, and an inherits flag, plus geo_modes/source_tiers; no writes. | No |
| PUT | /api/admin/translation-config/{domain} | Resolves {domain} (404 if unregistered), validates the request body as a locale config (400 bad_config on failure), upserts that domain's LocaleConfig override row, writes a locale_config_set audit entry, commits, and calls export_both so the new geo_mode takes effect immediately rather than waiting for the next publish. | No |
| DELETE | /api/admin/translation-config/{domain} | Resolves {domain} (404 if unregistered), deletes the domain's own tenant-scoped LocaleConfig override row if one exists so the domain reverts to inheriting the owner's global default, writes a locale_config_clear audit entry, commits, and calls export_both to make the reverted geo_mode live immediately; returns {cleared, inherits} without deleting anything if no override existed. | No |
Import a bio-page
| Method | Path | Description | Owner-only |
|---|---|---|---|
| POST | /api/admin/import | Normalizes/validates the source URL (400 invalid_url), enforces in-process per-actor (20/min) and per-target-domain (30/min) rate limits (429 rate_limited/rate_limited_domain) and a 60s in-memory result cache, then calls the external import service (importer_client.scan_import) to scan the URL and returns the ImportPreview, mapping service outages (429/503/504) to 503 import_unavailable and other failures to 400 import_failed; no DB session is opened. | No |
| POST | /api/admin/import/commit | Rate-limits per actor (429 rate_limited), validates the target domain exists (400 domain_not_owned) and conflicts (409 import_mode_required) when a segmented import needs apply_mode but a draft Segment already exists; persists the imported buttons/segments (persist_preview/persist_segmented_preview, writing Button/Segment rows), optionally installs background/avatar media fetched from the import service and profile name/bio text, then commits (409 commit_conflict on IntegrityError, 400 commit_failed/import_invalid on other DB or value errors) and calls export_draft(session) to regenerate the draft export. | No |
| GET | /api/admin/import/jobs/{job_id} | Read-only proxy to the external import service (importer_client.get_import_job); maps the service's status code to 503 import_job_unavailable, a 404 detail, or 400, and opens no DB session. | No |
QR codes
| Method | Path | Description | Owner-only |
|---|---|---|---|
| GET | /api/admin/qr/logo-icons | Returns the static in-process list of icon names (list_icon_names()) usable as a QR center-logo via icon:<name>; no database access, no validation beyond auth/require_admin+team_fence. | No |
| GET | /api/admin/qr/presets | Returns the static PRESETS catalog (id/name/family/palette/full design config) unchanged; no database access, no validation beyond require_admin+team_fence. | No |
| POST | /api/admin/qr/render | Validates a request-body design dict via validate_design (400 design_invalid on failure) and a url string ≤512 chars (400 url_invalid), then returns a live-rendered SVG string (render_svg) for the panel's design-constructor preview; no persistence. | No |
| GET | /api/admin/qr/{domain} | Resolves {domain} to a domain-page key via _resolve_domain (404 domain_not_found if no matching Domain row), lists all QrCode rows for that domain, and attaches per-code funnel stats (_stats_for, joining Click/Impression/OfConversion by source and cid) to each returned item. | No |
| POST | /api/admin/qr/{domain} | Resolves {domain} (404 domain_not_found), validates slug format/uniqueness per domain (400 slug_invalid/slug_taken) and channel (400 channel_invalid), resolves the design from either an inline design dict or a preset id (404 preset_not_found, 400 design_invalid), inserts a new QrCode row with a source label derived from the slug, writes an audit-log entry (qr_create), commits, and resets the public QR lookup cache (qr_cache.reset_qr_cache()) so the scan-serving side picks up the new code immediately. | No |
| PATCH | /api/admin/qr/{domain}/{qr_id} | Resolves {domain}/{qr_id} (404s as above), applies only the provided fields (title, channel, slug with per-domain uniqueness re-check yielding 400 slug_taken, design/preset via the same validation as create yielding 404 preset_not_found/400 design_invalid, active flag) to the QrCode row, and — only if something actually changed — bumps updated_at, writes an audit-log entry (qr_update), commits, and resets the public QR lookup cache. | No |
| DELETE | /api/admin/qr/{domain}/{qr_id} | Resolves {domain} and looks up the QrCode row by {qr_id} (404 domain_not_found/qr_not_found), writes an audit-log entry (qr_delete) before deleting, commits, and resets the public QR lookup cache (qr_cache.reset_qr_cache()). | No |
| GET | /api/admin/qr/{domain}/{qr_id}/image.svg | Under an owner_scope(authorized_query_owner(...)) tenant fence keyed by the optional account query param (lets an agency user with delegated access render a different owner's page's code), resolves {domain}/{qr_id} (404s as above), re-validates the stored design JSON (falling back to defaults on parse failure), renders the SVG at the requested px size (64-4096), and returns it as a Response with Cache-Control: no-store, adding a Content-Disposition: attachment header when download=true; read-only, no DB writes. | No |
Segments (audience rules)
| Method | Path | Description | Owner-only |
|---|---|---|---|
| GET | /api/admin/segments/{domain} | Resolves the domain (404 if unknown, via _ensure_domain_exists), loads that domain's draft Segment rows, computes each segment's effective route (using visible draft Buttons and the domain's direct-redirect/AB settings via funnel_route) and flags segments that are unreachable/shadowed by an earlier segment, returning them all in SegmentList; read-only. | No |
| POST | /api/admin/segments/{domain} | Resolves the domain, assigns/validates a segment_id (409 segment_id_conflict if taken), rejects a blocking default (400 block_default_conflict) and a blocking segment with match conditions (400 block_match_conflict), validates match/AB JSON and any inherit_buttons_from parent chain (400 on inherit errors), inserts the new draft Segment row, clears other defaults if it becomes default, writes a segment_create audit entry, commits, and calls export_draft to regenerate the exported config. | No |
| PUT | /api/admin/segments/{domain}/order | Resolves the domain and its draft segments, 400s order_duplicate on repeated ids or order_mismatch if the given id list doesn't exactly match the domain's current segment set, otherwise writes each segment's new position, writes a segment_reorder audit entry, commits, calls export_draft, and returns the re-ordered SegmentList. | No |
| PATCH | /api/admin/segments/{domain}/{segment_id} | Resolves the domain and draft segment (404 segment_not_found), applies only the provided fields (name, countries, is_default, block, inherit_buttons_from, match conditions, AB json, lang, notify_url), each validated against conflicting state (400 segment_default_required, block_default_conflict, block_match_conflict, inherit errors, validate_url on notify_url), writes a segment_update audit entry only if something actually changed, commits, and calls export_draft. | No |
| DELETE | /api/admin/segments/{domain}/{segment_id} | Resolves the domain and draft segment (404 segment_not_found), refuses to delete the default segment unless another default exists (400 segment_default_required), refuses if Button rows still reference it unless force=true (409 segment_has_buttons), then deletes those buttons and the segment, reindexes remaining positions, writes a segment_delete audit entry, commits, cleans up now-orphaned button text labels, and calls export_draft. | No |
Telegram gate
| Method | Path | Description | Owner-only |
|---|---|---|---|
| GET | /api/admin/tg/gate | Reads the calling owner's TgTurnstileBot config and /start stats via tg_turnstile_shape.payload (period normalized through bucket_period_from so it matches sibling tabs), returning connection state, a masked bot card (raw token never included), and stats; read-only. | Yes |
| PATCH | /api/admin/tg/gate | Validates the patch (422 on non-https dm_url/channel_url, oversized fields, out-of-range fresh_id_min/typing_delay_ms, malformed greetings), auto-migrates the platform-home owner's env-only config into a TgTurnstileBot row if none exists (else 409 connect a bot first), validates the v2 destinations/default_dest/rules trio against the effective post-patch state (422 on dangling/invalid keys), writes the provided fields to the row, commits, and invalidates turnstile_registry for the owner. | Yes |
| GET | /api/admin/tg/gate/avatar | Looks up the current owner's TgTurnstileBot row (shape.row_for) and its avatar_ext, raising 404 no_avatar if there is no row, no synced extension, or the file at <data_dir>/tg-gate/avatar-<ownerkey>.<ext> is missing on disk; otherwise streams that file back as a FileResponse with a content-type from the whitelisted extension map and header Cache-Control: private, max-age=300 (no DB writes, no Telegram call). | Yes |
| POST | /api/admin/tg/gate/connect | 400s if no webhook host is configured or the token is empty, calls Telegram getMe to validate the token (400 invalid_token on reject, 502 telegram_unreachable on network error), upserts the owner's TgTurnstileBot row (preserving existing settings, minting webhook_secret once), enables it, best-effort registers the webhook via setWebhook and best-effort syncs the bot profile (tg_turnstile_sync.sync_row), commits, and invalidates turnstile_registry; the raw token is never returned. | Yes |
| POST | /api/admin/tg/gate/disconnect | Best-effort calls Telegram deleteWebhook (failures ignored — token may already be revoked), then clears the row's bot_token and sets enabled=0 while keeping settings/stats, commits, and invalidates turnstile_registry; a no-op returning a clean not-configured payload if the owner has no row. | Yes |
| POST | /api/admin/tg/gate/profile | Validates any provided name/bio/short_bio against Telegram's real caps (64/512/120 chars, else 422), loads the owner's connected bot row via _row_or_409 (409 connect a bot first if none), pushes each provided field to Telegram (setMyName/setMyDescription/setMyShortDescription) via _tg_set — which raises 502 telegram_unreachable, 502 telegram_rate_limited, or 502 <method>_failed on transport/API failure — then best-effort re-syncs the row from Telegram (sync_row), commits TgTurnstileBot updates, invalidates the owner's turnstile_registry cache entry, and returns the current profile shape. | Yes |
| POST | /api/admin/tg/gate/sync | Loads (or 409s via _row_or_409 if no bot token is connected) the owner's TgTurnstileBot row, then calls sync_row to pull getMe/getMyDescription/getMyShortDescription/getUserProfilePhotos(+getFile+download) from Telegram and partial-tolerantly write bot_username, bot_name, bio, short_bio, and avatar_ext onto the row, commits updated_at and the session, invalidates the owner's turnstile_registry cache, and returns the GET-shape payload merged with {"synced": bool, "errors": [...]} where synced is only true if every Telegram step succeeded. | Yes |
| POST | /api/admin/tg/gate/webhook/refresh | 400s if no webhook host is configured, auto-migrates the platform-home owner's env-only config into a row if needed, 409s connect a bot first if there's no connectable bot token, mints webhook_secret if missing, then re-runs setWebhook (502 telegram_unreachable on network error, 502 setwebhook_failed on a Telegram-side reject), updates webhook_set_at, commits, and invalidates turnstile_registry. | Yes |
Relay slots
| Method | Path | Description | Owner-only |
|---|---|---|---|
| GET | /api/admin/relay-slots | Reads RelaySlot rows ordered by created_at desc, id desc, optionally filtered to one normalized domain via validate_domain_ref, then drops any row whose domain is outside the caller's tenant scope (host_in_scope) before returning them as RelaySlotList; read-only, no writes or error codes beyond FastAPI validation. | No |
| POST | /api/admin/relay-slots | Validates the slug (regex + reserved-word check, 400) and domain, 404s domain_not_found if the Domain row doesn't exist, 400s slug_taken if that slug is already used on the domain, inserts a new RelaySlot row (active=1), writes a relay_slot_create audit entry, commits, and busts relay_slots_cache so the slot resolves immediately. | No |
| PATCH | /api/admin/relay-slots/{slot_id} | Loads the RelaySlot by id, 404s slot_not_found if missing or outside the caller's tenant scope (host_in_scope), applies any provided fields (title, source — 400 source_empty if blanked, arm_policy, active, hide_x_enabled), writes a relay_slot_update audit entry, commits, and busts relay_slots_cache. | No |
| DELETE | /api/admin/relay-slots/{slot_id} | Loads the RelaySlot by id and returns 404 slot_not_found if it doesn't exist or its domain is outside the caller's tenant scope (host_in_scope) — a cross-tenant row is indistinguishable from a missing one; otherwise deletes it, writes a relay_slot_delete audit entry, commits, and busts relay_slots_cache. | No |
Link presets
| Method | Path | Description | Owner-only |
|---|---|---|---|
| GET | /api/admin/link-presets | Lists the caller's own LinkPreset rows (tenant-auto-scoped), newest first, with no writes. | No |
| POST | /api/admin/link-presets | Creates a new LinkPreset row (owner_id auto-stamped by the tenant seam) after checking the name isn't already taken within the caller's own presets (409 preset_name_taken), serializing trial/paid country lists, writes an audit log entry link_preset_create, and commits. | No |
| PATCH | /api/admin/link-presets/{preset_id} | Looks up the caller's own LinkPreset (404 if missing), applies only the fields present in the payload (name uniqueness re-checked, 409 preset_name_taken), bumps updated_at only if something changed, writes an audit log entry link_preset_update, and commits. | No |
| DELETE | /api/admin/link-presets/{preset_id} | Looks up the caller's own LinkPreset by id (404 preset_not_found if missing/not theirs via tenant auto-scoping), deletes the row, writes an audit log entry link_preset_delete, and commits. | No |
| POST | /api/admin/link-presets/{preset_id}/clone | Duplicates an existing LinkPreset (404 if source missing) into a new row under a new name (409 preset_name_taken if it collides within the caller's own presets), copying the serialized config columns verbatim, writes an audit log entry link_preset_clone, and commits. | No |
Traffic dashboards & reports
| Method | Path | Description | Owner-only |
|---|---|---|---|
| GET | /api/admin/clicks-report | Returns a filtered, paginated vitrina over the clicks table (time, click_id, external_click_id, country, source, domain, code, subnet, unique/converted/fraud flags, UA/referrer/UTM/ad_platform) via compute_clicks_report or, when an authorized account-scope selection is present, the rolled-up clicks_rolled over that scope's pages, first narrowing link_domain to the caller's team domain scope via confine_report_domain; format=csv additionally requires the of-logs/logs_export team capability (require_team_cap, else raises) and streams every filtered row (ignoring pagination) as a CSV attachment instead of JSON. | No |
| GET | /api/admin/conversions-report | Returns a filtered, paginated vitrina over the of_conversions table (time, fan, type, gross/net amount, conversion_id, click_id, external_click_id, country) plus a per-day chart and totals via compute_conversions_report, or the rolled-up conversions_rolled over an authorized account-scope selection's pages, narrowing link_domain to the caller's team domain scope via confine_report_domain; format=csv additionally requires the of-logs/logs_export team capability (require_team_cap, else raises) and streams every filtered row as a CSV attachment (fan names, amounts, country included) instead of JSON. | No |
| GET | /api/admin/dashboard/money | Serves the Money dashboard by reading/aggregating revenue figures (net/gross cents, subs, spenders, per-day trend, leaderboard) computed from the of_transactions ledger via compute_source_leaderboard/compute_dashboard_money/compute_dashboard_money_scoped, always kicking an async background revenue refresh (kick_freshen_async) and serving from a 180s owner+period+scope+tz-keyed read cache (cached_call with a background refresh callback) rather than a fresh query; it branches on team_source_scope/account_scope (creator/tenant/page rollups via _scope) so a domain-confined team member is forced into scope=fwd and gets only their own dollars with all percentages and account totals stripped (shares_hidden: true), while the workspace owner gets honest percentages computed against the account's total net revenue for the same window (pct_basis_net_cents); an invalid tz silently falls back to UTC rather than erroring. | No |
| GET | /api/admin/dashboard/source-fans | Read-only: for a given source (empty returns an all-zero stub), computes/reads a 180s-TTL cached per-owner fan scope (compute_source_fan_scope) and filters it in-memory by country/entered/limit, restricting a team member with a domain scope to their own domains (team_source_scope) and returning empty for an out-of-scope source rather than leaking data; also supports account_scope=creator|tenant|page rollups via the shared page-scope resolver. | No |
| GET | /api/admin/dashboard/traffic | Read-only: builds the Traffic dashboard payload (funnel/Sankey/sources/geo/hours/devices) via a 180s-TTL cached_call with a background refresher, scoping web telemetry vs. fan/subscriber aggregates separately for creator/tenant/page account-scope selections, forcing a team member with domain scope onto their allowed domain, and stripping money fields via without_traffic_country_money when the caller lacks the dashboard.money_view capability. | No |
Tracking health
| Method | Path | Description | Owner-only |
|---|---|---|---|
| GET | /api/admin/tracking/health | Read-only: runs the spec §6 tracking-chain invariant checks (tracking_health.run_tracking_health) for the caller's own owner_id by default, or for another creator via ?owner_id= when the caller principal.is_owner (else 403 'not your scope'), returning {owner_id, overall, generated_at, checks:[...]}; no writes. | No |