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:
| Resource | What it is | Table |
|---|---|---|
| Tag templates | What a tag loads: an external script (url) or inline JavaScript (inline), with default trigger rules. Mythic's catalog plus the location's own | tags |
| Tags | GTM-like tag assignments (a template id plus params) fired by the SDK | tenant_tags |
| Snippets | Custom JavaScript (code) injected by the SDK | tenant_snippets |
| Transformers | Event transforms (map field mappings or code) applied by the SDK to every captured event before it is sent, in priority order | transformers |
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 withPOST /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 withPOST /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: truemarks 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:
| Mode | Writes | Versions |
|---|---|---|
immediate (default) | Go live on the spot, as they always have | Recorded automatically (source: auto) whenever a write changes the config |
draft | Saved 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.
| Route | What it does |
|---|---|
GET /workspace | Mode, 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 /publish | Record 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 /versions | History, 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}/restore | Roll 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 /live | What 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.
| Route | What it does |
|---|---|
GET /firings | One 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/{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
| Status | Code | Meaning |
|---|---|---|
400 | bad_json, missing_field, bad_type, bad_config, bad_priority, nothing_to_update | Malformed 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 |
400 | unknown_field, bad_name, bad_src, bad_code, bad_description, bad_trigger_rules | Tag 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 |
401 | unauthorized | Missing/invalid key, or an ak_ with no location bound |
403 | agency_required | Write attempted with an sk_ key |
403 | template_read_only | PATCH or DELETE on a Mythic catalog template |
404 | not_found | No such tag assignment, tag template, snippet, or transformer for this location |
404 | template_not_found | POST /tags with a template id the location cannot see |
400 | bad_date, bad_by | Firings: an unparseable or reversed window, a window over 365 days, or by other than page/trigger/day |
502 | query_failed | Firings: the analytics query failed |
503 | analytics_unavailable | Firings: the analytics backend is not configured |
400 | bad_mode, bad_field, bad_limit, bad_before, publish_required | Versions: 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 |
404 | not_found | No such version for this location |
409 | template_in_use | DELETE on a template that is still assigned |
500 | publish_failed, restore_failed | The version could not be recorded (nothing went live) or the restore transaction failed (nothing changed) |
429 | rate_limited | 120 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/decidecalls 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 callmythic.capture(...), read config, etc.event— only for event-triggered snippets (a snippet whosetrigger_rulescontain amythic_eventrule):{ 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
eventundefined.
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.