OverviewIntroduction

Client Config

Manage the configuration the SDK runs from — tags, tag templates, snippets, and event transformers — over REST, with every write synced to the edge and the sync outcome reported back as kv_sync.

Overview

Three things the Mythic SDK executes on a client's site are configured per location, plus the templates tags are built from:

ResourceWhat it isTable
Tag templatesWhat a tag loads: an external script (url) or inline JavaScript (inline), with default trigger rules. Mythic's catalog plus the location's owntags
TagsGTM-like tag assignments (a template id plus params) fired by the SDKtenant_tags
SnippetsCustom JavaScript (code) injected by the SDKtenant_snippets
TransformersEvent transforms (map field mappings or code) applied by the SDK to every captured event before it is sent, in priority ordertransformers

The Client Config API is CRUD over those on /client/v1/config, plus version history, draft/publish and firing data for them — 28 routes. The same operations exist as MCP tools on the Settings Server (tags, snippets, transformers scope families); this is their headless REST form.

Base URL

https://mythic-analytics.gulp.workers.dev/client/v1/config

Authentication

The same key system as the rest of /client/v1: an agency key (ak_, which must bind a location on every request with ?location_id= or the X-Location-Id header) or a location secret key (sk_, auto-scoped to its own location). An agency key that binds no location returns 400 location_required; one that names a location it is not linked to returns 403 location_not_linked.

Reads accept either key. Writes require an agency key — same rule as Settings, Destinations, and Event Contracts.

This surface deliberately serves no CORS headers. ak_ and sk_ keys are server-side credentials — never call it from browser JavaScript.

kv_sync — why every write reports its edge sync

The SDK does not read the database. It runs from the location's edge config (a Cloudflare KV entry), and tenant-config writes are not synced to it by database triggers. A write that lands in the database but never reaches the edge is the classic failure mode here: the API says "saved", and the site keeps running the old tags forever.

So every write on this surface rebuilds the edge config inline, and the response tells you how that went:

{ "success": true, "data": { "...": "..." }, "kv_sync": "synced" }
  • "synced" — the edge config was rebuilt from database truth. The SDK picks the change up on its next config fetch.
  • "failed: <reason>" — the database write landed, but the edge is still serving the previous config. Retry the write (any successful write re-syncs the whole location config), or rebuild via the admin API.
  • "draft" — the location is in draft mode. The write is saved to the workspace and goes live with POST /publish.

Treat a failed sync as the write not having taken effect yet — because for the SDK, it hasn't.

Tag templates

A tag assignment points at a template. GET /tag-templates lists every template the location can assign:

  • owner: "mythic" — Mythic's catalog. Visible to every location, read-only.
  • owner: "location" — the location's own templates, created with POST /tag-templates. Only that location can see, edit, delete or assign them. They belong to the location, not the agency, so a key bound to one client can never change the code running on another client's site.
  • managed: true marks the Meta and Google tags. They are templates you do not create; see Managed platform tags.

A location's template is url (the SDK loads src, an absolute https URL, as an async script) or inline (the SDK runs code as a <script> body — JavaScript, not HTML). url requires src and inline requires code, checked on the stored row after a PATCH too, so a type change must bring the matching field. trigger_rules on a template are its defaults; an assignment's own trigger_rules replace them.

A new template does nothing until it is assigned. Editing an assigned template rebuilds the edge config (kv_sync: "synced"); editing an unassigned one returns kv_sync: "not_assigned". A template that is still assigned cannot be deleted (409 template_in_use): remove the assignment first.

Another location's template id reads as 404, on every route. Assigning one with POST /tags returns 404 template_not_found, and the database refuses it for every other writer too.

Versions and publishing

A version is a numbered snapshot of the location's tags (with the template content they use), snippets and transformers, disabled ones included. The managed Meta and Google tags are not versioned: each is paired with a server-side destination that is not versioned, so they always go live immediately.

Each location has a publishing mode:

ModeWritesVersions
immediate (default)Go live on the spot, as they always haveRecorded automatically (source: auto) whenever a write changes the config
draftSaved to the workspace only (kv_sync: "draft")Recorded by POST /publish, which also makes the workspace live

Switch with PUT /publish-mode. Switching to draft records the current config as a version first. Switching back to immediate publishes the workspace, drafts included. In draft mode nothing else can publish a draft: settings changes, integrations and admin rebuilds keep serving the latest version.

RouteWhat it does
GET /workspaceMode, latest version, and changes: every tag, snippet and transformer added, removed or modified since it. This is the "unpublished changes" list.
PUT /publish-mode{"mode": "draft"} or {"mode": "immediate"}
POST /publishRecord the workspace as a version and make it live. Optional name and notes. 201 with the new version, or 200 with created: false when nothing changed.
GET /versionsHistory, newest first. Page with ?before=<next_before>.
GET /versions/{version}One version: changes from the previous version, the full snapshot, and the layer the SDK runs from it.
POST /versions/{version}/restoreRoll back: rewrite the workspace to that version and publish it as a new version (source: restore, restored_from). In draft mode {"publish": false} loads it into the workspace without going live.
GET /liveWhat the SDK is served right now, its served_version, and in_sync: whether it matches what should be live (the latest version in draft mode, the workspace in immediate mode). false means a rebuild failed.

Only the 3 most recent versions are kept per location. Recording a new version deletes the oldest one beyond that. Version numbers keep counting up, so the survivors after nine changes are 7, 8 and 9, a number is never reused, and restoring a deleted version returns 404. GET /versions reports the cap as retention. For the oldest kept version the previous one is gone, so GET /versions/{version} returns changes: null and previous_pruned: true. In immediate mode every change records a version, so the 3 kept are the last 3 changes.

Each version records created_by: agency for an agency key, mcp:<key prefix> for a scoped key, admin or null for internal rebuilds. The key itself is never stored.

A restore recreates a location's own templates if they were deleted. An assignment whose Mythic catalog template no longer exists cannot be restored and is listed in skipped_tags.

These routes use the tags scope family: reads need tags:read, and publishing, restoring and changing the mode need tags:write. A publish covers snippets and transformers too.

Firings

Which tags and snippets fired, where and on what. The SDK reports each firing on the next event it sends as $tags_fired, and Mythic totals them per tag. Data starts from SDK 3.33.

RouteWhat it does
GET /firingsOne row per tag and snippet in the workspace: fires, sessions, distinct pages, distinct triggers, first_fired, last_fired. A tag that never fired is listed with zeros. A firing of a tag or snippet that has since been removed is listed with in_workspace: false.
GET /firings/{kind}/{id}Where one tag (kind = tag, id = its template id) or snippet (kind = snippet) fired, grouped by by=page (host + path, the default), by=trigger (page_load or the event name) or by=day.

Both take date_from and date_to (ISO). The default window is the last 30 days, and the longest is 365 days, which is also how long firings are kept.

A page-load tag fires once per page load, so its fires reads as page loads. A managed Meta or Google tag counts once per page load too: forwarding later events to the pixel is not a new firing.

Quickstart

Create a tag template

curl -X POST "$BASE/client/v1/config/tag-templates?location_id=$LOC" \
  -H "Authorization: Bearer $AK" -H "Content-Type: application/json" \
  -d '{
    "name": "Hotjar",
    "type": "url",
    "src": "https://static.hotjar.com/c/hotjar-1234567.js?sv=6"
  }'

The response's data.id is the template id. Skip this step to use a template from GET /tag-templates instead.

Assign a tag

curl -X POST "$BASE/client/v1/config/tags?location_id=$LOC" \
  -H "Authorization: Bearer $AK" -H "Content-Type: application/json" \
  -d '{
    "tag_id": "3f1c2a9e-8d4b-4e7a-9c1f-2b6d8e0a5c47",
    "trigger_rules": {
      "version": 1,
      "operator": "AND",
      "rules": [{ "type": "url_pattern", "operator": "contains", "value": "/checkout" }]
    }
  }'

tag_id is a template id from GET /tag-templates. enabled defaults to true and params to {}. Tags are keyed by tenant_key + tag_id, so updates and deletes address the assignment by its tag_id. GET /tags returns each assignment with a template summary (id, name, type, owner, managed).

Add a snippet

curl -X POST "$BASE/client/v1/config/snippets?location_id=$LOC" \
  -H "Authorization: Bearer $AK" -H "Content-Type: application/json" \
  -d '{
    "code": "console.log("hello from mythic");",
    "description": "Debug logger",
    "enabled": true
  }'

code is required. The list endpoint omits code bodies — GET /snippets/&#123;id} returns the full row.

Create a transformer

curl -X POST "$BASE/client/v1/config/transformers?location_id=$LOC" \
  -H "Authorization: Bearer $AK" -H "Content-Type: application/json" \
  -d '{
    "name": "Add page title",
    "type": "map",
    "config": { "from": "document.title", "as": "page_title" },
    "priority": 50
  }'

type is map (evaluate config.from, a JS expression, and store the result at config.as in the event properties) or code (run config.code with (data, mythic) — mutate data and return it, or return null to drop the event). The config must be complete for the type — an incomplete one is rejected with 400 bad_config instead of silently no-oping at capture time. Transformers run on every captured event in priority order, lowest first; they are unconditional (there are no trigger rules — gate inside code when you need conditions). Like snippets, the list omits config.

Transformers execute arbitrary JavaScript in the browser, exactly like snippets — transformers:write on a key is code-execution trust. They are not loaded at all when the SDK runs in HIPAA mode or with disable_remote_config.

Update and verify

curl -X PATCH "$BASE/client/v1/config/tags/3f1c2a9e-8d4b-4e7a-9c1f-2b6d8e0a5c47?location_id=$LOC" \
  -H "Authorization: Bearer $AK" -H "Content-Type: application/json" \
  -d '{ "enabled": false }'
{
  "success": true,
  "data": {
    "tenant_key": "loc_abc123",
    "tag_id": "3f1c2a9e-8d4b-4e7a-9c1f-2b6d8e0a5c47",
    "enabled": false,
    "params": {},
    "trigger_rules": {
      "version": 1,
      "operator": "AND",
      "rules": [{ "type": "url_pattern", "operator": "contains", "value": "/checkout" }]
    }
  },
  "kv_sync": "synced"
}

PATCH is partial — only the fields you pass change. A PATCH with no recognized fields returns 400 nothing_to_update. Always check kv_sync before considering a write done.

Errors and limits

StatusCodeMeaning
400bad_json, missing_field, bad_type, bad_config, bad_priority, nothing_to_updateMalformed body, a required field missing, a transformer type not map/code or a template type not url/inline, an incomplete type-specific transformer config, a priority outside 1–100, or an empty PATCH
400unknown_field, bad_name, bad_src, bad_code, bad_description, bad_trigger_rulesTag templates only: a field that is not writable (id, tenant_key), a missing or over-200-character name, a src that is not an absolute https URL, code over 64 KB, a description over 2,000 characters, or trigger_rules without a rules array
401unauthorizedMissing/invalid key, or an ak_ with no location bound
403agency_requiredWrite attempted with an sk_ key
403template_read_onlyPATCH or DELETE on a Mythic catalog template
404not_foundNo such tag assignment, tag template, snippet, or transformer for this location
404template_not_foundPOST /tags with a template id the location cannot see
400bad_date, bad_byFirings: an unparseable or reversed window, a window over 365 days, or by other than page/trigger/day
502query_failedFirings: the analytics query failed
503analytics_unavailableFirings: the analytics backend is not configured
400bad_mode, bad_field, bad_limit, bad_before, publish_requiredVersions: a mode other than immediate/draft, a name over 200 or notes over 2,000 characters, a bad page parameter, or publish: false on a restore in immediate mode
404not_foundNo such version for this location
409template_in_useDELETE on a template that is still assigned
500publish_failed, restore_failedThe version could not be recorded (nothing went live) or the restore transaction failed (nothing changed)
429rate_limited120 requests/min per location — retry after Retry-After

Deletes are idempotent for tags (removing an absent assignment succeeds); snippet and transformer deletes verify ownership first and return 404 for a row that is not this location's.

The install snippet prefetches this endpoint

The published install snippet calls /decide itself, during HTML parse, before the loader script is even requested — then parks the in-flight promise on window.__mythicCfgPrefetch and the SDK adopts it instead of issuing its own request:

var u = "https://api.adberserk.com/decide?key=pk_xxx&v=2",
    r = fetch(u, { cache: "default" });
r.catch(function() {});
window.__mythicCfgPrefetch = { url: u, response: r };

Measured on a cold visit, this starts config at ~12ms instead of ~300-500ms, because it no longer waits for the loader to download and execute. Config is what gates tags, snippets and platform pixels, so the whole server-defined layer lands earlier on the page.

Three things matter if you hand-roll the snippet rather than using the one from the API or the builder:

  • The URL must match exactly, including &v=2. The SDK adopts the parked response only when the URL equals the one it was going to request; anything else and it fetches again, giving you two /decide calls per page.
  • The handle is single-use, and it lives on its own window key rather than on window.mythic — the core replaces that global, which would drop it.
  • Keep the .catch(). Without it a failed prefetch is an unhandled rejection on the customer's page. The SDK handles the failure when it adopts.

Omit the prefetch entirely for HIPAA-mode and disable_remote_config tenants. Their SDK makes no config request at all, so prefetching would send the exact request those modes exist to prevent.

respect_dnt is the same rule with a runtime condition. The SDK checks Do Not Track and Global Privacy Control during init and disables itself before it requests config, so on those tenants a DNT visitor generates no requests at all — and the snippet, which runs earlier than any of that, has to repeat the test:

if (!(window.doNotTrack === "1" || navigator.doNotTrack === "1"
      || navigator.doNotTrack === "yes" || window.msDoNotTrack === "1"
      || navigator.globalPrivacyControl)) {
  /* prefetch */
}

The generated snippet emits this automatically when respect_dnt: true is in the init config, and the loader applies the same test before starting its own prefetch. Match the signal list exactly if you hand-roll it: a gate that misses one lets the request through for a visitor the SDK would have honoured.

Snippet execution context

Snippets run in a function scope with two arguments:

  • mythic — the SDK instance (same object as the page global), so a snippet can call mythic.capture(...), read config, etc.
  • event — only for event-triggered snippets (a snippet whose trigger_rules contain a mythic_event rule): { name, properties, uuid } for the event that fired it.
// Event-triggered snippet: forward a custom conversion with the same
// event id the server destinations use, for platform-side dedup.
fbq('trackSingle', PIXEL_ID, 'Purchase', event.properties, { eventID: event.uuid });

event.uuid is the Mythic event id (UUIDv7) that the SDK generated for that event — it is the same value the server-side destinations send as Meta's event_id and Google's transaction_id. Use it as your dedup key whenever a snippet sends a conversion to a platform that also receives the server-side delivery.

Two execution rules to know:

  • An event-triggered snippet fires once — on the first matching event. It does not re-run for later matches; use a platform destination for per-event forwarding.
  • A snippet with no trigger rules runs at load time with event undefined.

Snippets execute arbitrary JavaScript in the browser — snippets:write on a key is code-execution trust, exactly like transformers. Nothing server-defined (tags, snippets, transformers) loads at all in HIPAA mode or with disable_remote_config.

Managed platform tags

Two tag types are provisioned by the Platform Integrations API rather than this CRUD surface: meta_pixel and google_tag. They carry no code — the behaviour lives in the SDK (loaded as an on-demand chunk, so sites without them never download it), and the per-location config (pixel id, event mapping) lives in tenant_tags.params.

They are listed here because you will see them through these endpoints and the tags scope (managed: true in GET /tag-templates): each platform has one global template row (named Mythic · Meta Pixel / Mythic · Google Tag), and a location's assignment carries its config. Don't hand-edit those rows — reconnect through the integrations API (or the connect_meta_integration / connect_google_ads_integration MCP tools), which keeps the browser tag and the server destination in sync and preserves the shared-id dedup between them.