OverviewIntroduction

Event Contracts

Declare what an event is supposed to carry, then get a report of what your live traffic actually does about it — the fields that went missing, arrived null, or arrived as the wrong type.

Overview

The Data API's property catalog answers "what fields does my data have?" — every key ever observed, with types and samples. It is discovery, and it cannot tell you the thing that actually breaks reporting:

A redesign shipped on Tuesday and 40% of order_completed events stopped carrying value.

The key is still very much "seen". Revenue reporting is quietly wrong.

An event contract is the declared side of that: for one event name, the properties it is supposed to carry, their types, and which are required. GET /violations diffs the declaration against real traffic and tells you the share of events that break it.

Contracts are read-only enforcement. Nothing here rejects, quarantines, or drops an event — a typo in a contract must never become data loss. It is a report, and you can have it pushed to you daily instead of asking for it.

SymptomWhat the report calls itWhat it usually means
Key not present on the eventmissing_requiredField never wired, or lost in a redesign
Key present, value nullnull_valueIntegration is sending an empty field
Key present, wrong JSON typetype_mismatch"24.99" sent as a string, value as text
Event has no traffic at allstatus: "no_data" + a no_data_reasonTagging outage, a dead event, or just sampling — the reason says which
Event fires less than declaredlow_volumeTraffic dropped — needs min_daily_events set

Those four are kept deliberately separate. Absent, null, and wrong-type look like one problem in a report that lumps them, and have three different fixes.

Every violation also carries a samples link to the events that actually break it — the count tells you there is a problem, the samples tell you which page.

Base URL

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

Authentication

The same key system as the rest of /client/v1: an agency key (ak_, bind a location with ?location_id= or the X-Location-Id header) or a location secret key (sk_, auto-scoped to its own location). Reads accept either. Writes require an agency key — same rule as Settings, Destinations, and constraint benchmarks.

Event contracts are sold by tier. If your plan does not include them the whole surface answers 402 feature_not_in_plan — the key is valid, the tier is what is missing. GET /client/v1/agency/quota lists what your plan includes.

Quickstart

Scaffold a contract from live traffic

curl "$BASE/client/v1/contracts/suggest?location_id=$LOC&event=order_completed" \
  -H "Authorization: Bearer $AK"

Returns a ready-to-store contract inferred from the window: the dominant JSON type per key, required: true for keys on ≥99% of events. Internal $-prefixed properties ($current_url, $device_type, autocapture internals) are excluded — those are Mythic's contract to keep, not yours to declare. Keys that are always null or appear on under 1% of events land in skipped with a reason, so a dead field doesn't look like a missing one.

Hand-transcribing 30 property names is how schema features go unused. Start here, then edit.

Store it

curl -X PUT "$BASE/client/v1/contracts?location_id=$LOC" \
  -H "Authorization: Bearer $AK" -H "Content-Type: application/json" \
  -d '{
    "event": "order_completed",
    "properties": {
      "value": "number",
      "currency": "string",
      "coupon_code": { "type": "string", "required": false }
    },
    "note": "Checkout v3 — value is gross, pre-refund"
  }'

A bare type string ("number") means required. Optional fields need the long form with "required": false. Required is the useful default: a contract whose every field is optional cannot fail, and is indistinguishable from having no contract.

PUT replaces that event's property set. A key you leave out stops being checked.

Read the violations

curl "$BASE/client/v1/contracts/violations?location_id=$LOC" -H "Authorization: Bearer $AK"

Every stored contract, checked against the window, worst first. Pass ?event= for one of them — which is also much cheaper, see Windows and cost.

Starting from a preset

If you track ecommerce, do not write these by hand. POST /presets/commerce stores the whole Mythic ecommerce spec as contracts in one call — thirteen events, with items (array), item_count, value and currency declared required wherever the spec says there is money.

# see exactly what would be stored
curl -X POST "$BASE/client/v1/contracts/presets/commerce?location_id=$LOC&dry_run=true" \
  -H "Authorization: Bearer $AK"

# store it
curl -X POST "$BASE/client/v1/contracts/presets/commerce?location_id=$LOC" \
  -H "Authorization: Bearer $AK"

This is worth doing specifically because ad platforms accept broken commerce events. A purchase with no value reaches Meta as a conversion worth zero and reports as delivered; a purchase whose items went missing reaches Google Ads with no Shopping signal and reports as delivered. The violation report is the only place either shows up.

Each event in a pack follows the same replace rule as PUT. Events outside the pack are untouched, so hand-written contracts for your own events survive. GET /presets lists the packs and every contract in them, if you would rather copy one event out and adjust it.

Reading a violation

{
  "event": "order_completed",
  "status": "violations",
  "total_events": 1000,
  "properties": [
    {
      "key": "value",
      "expected_type": "number",
      "required": true,
      "present": 700,
      "absent": 200,
      "null_values": 100,
      "type_mismatches": 150,
      "coverage": 0.7,
      "observed_types": { "string": 150, "null": 100 }
    }
  ],
  "violations": [
    { "key": "value", "kind": "missing_required", "count": 200, "rate": 0.2,
      "detail": "`value` is absent on 200 of 1000 events",
      "samples": "/client/v1/contracts/samples?event=order_completed&key=value&kind=missing_required" },
    { "key": "value", "kind": "type_mismatch", "count": 150, "rate": 0.15,
      "detail": "`value` expected number, got string on 150 of 1000 events",
      "samples": "/client/v1/contracts/samples?event=order_completed&key=value&kind=type_mismatch" }
  ],
  "undeclared": [
    { "key": "shipping_tier", "observed_type": "string", "occurrences": 300, "coverage": 0.3 }
  ]
}
  • present counts events carrying the key with any non-null type — including the wrong one. coverage is that over total_events.
  • absent is total_events minus everything seen. It is computed from a real event count, which is what makes the report possible at all.
  • undeclared keys are never violations. A growing integration always has some, and a report that complained about them would get muted in a week. Treat the list as the scaffold for your next contract version.

no_data is not "everything is missing"

A contract whose event had no traffic in the window returns status: "no_data" with an empty violations array, and is counted separately in summary.events_no_data. An event that stopped firing entirely is usually the bigger problem, and it must not be reported as "100% of fields missing" — that would blame the payload for a tagging outage.

But "no traffic" itself has three causes with three different fixes, and they look identical from inside the contract check. So every no_data is checked against an independent, unsampled count of what the location sent in the same window, and reports which cause applies:

no_data_reasonWhat happenedWhat to do
no_events_at_allThe location sent nothing of any nameTracking outage. Check the SDK is loading before looking at this event.
event_not_firingTraffic is alive; this event name is absentThe tagging bug worth paging for.
sampled_outThe reference sees the event; ?sample= dropped itNothing. Re-run without sampling.
source_disagreementUnsampled, and the reference sees events the scan did notUsually late-arriving data. Re-run in a few minutes.
unknownThe reference read failedCan't distinguish the above — don't assume the worst.

The same reference count appears on every event as reference_events, next to total_events, and location-wide as summary.reference.location_events. The two numbers come from different sources on purpose and are never merged: when they disagree, the disagreement is the signal.

Finding the actual broken events

value is absent on 4,102 of 10,000 order_completed — now which page? Every violation carries a samples link, pre-filled with the report's window:

curl "$BASE/client/v1/contracts/samples?location_id=$LOC\
&event=order_completed&key=value&kind=missing_required" \
  -H "Authorization: Bearer $AK"
{
  "samples": [
    {
      "event_id": "018f2c1a-7b3e-7c9d-a1b2-c3d4e5f60718",
      "session_id": "sess_9f2c1a7b",
      "timestamp": "2026-08-05T14:22:07",
      "url": "https://shop.example.com/checkout/thank-you?variant=b",
      "observed_type": "absent",
      "value_sample": ""
    }
  ]
}

session_id is there because session replay is usually the real destination — show me the recording where the field went missing. value_sample carries the raw property value truncated to 200 characters (empty for missing_required, where there is nothing to show).

/samples is never sampled, even when the report was — sampling a search for rare events returns a confident empty result.

It stays fast by reading newest-first and stopping as soon as it has enough rows, so the volume of the event name barely matters when matches exist. The one slow case is a violation with no matching events: proving that requires reading every event of that name, and on a very high-volume event it can time out. If you get window_too_large, check the violation still has a non-zero count in /violations over the same window — and if it does, widen the window rather than narrowing it, so the most recent match falls inside.

kind=type_mismatch reads the expected type from your stored contract rather than a query parameter, so you cannot find mismatches against a type you never declared; it returns 404 not_contracted for an undeclared key.

Windows and cost

Computing "how often is this field missing" needs an event count, which means reading the raw events. The window therefore has a real cost, and it is bounded:

  • Default window is the last 1 day. Set date_from / date_to (ISO 8601) for anything else.
  • Pass ?event= whenever you care about one contract. It is the cheapest thing you can do.
  • Wide windows on high-volume events need ?sample=N, which keeps a deterministic 1/N of events. ?sample=10 over 7 days comfortably handles an event with 300k occurrences.

Sampling is uniform over a hash of the event id, so rates and coverage are unbiased — a 20% missing rate measures the same on a 1/10 sample. Counts (absent, count, total_events) become counts of the sample and are deliberately not scaled back up: a scaled count reads as a measurement when it is an estimate. The response echoes the factor under window.sample.

If the window still holds too much to scan, the API returns 400 window_too_large naming the three fixes above, rather than a timeout dressed up as a server error.

A truncated: true flag on the response means the underlying property statistics hit their row limit. Narrow the window or pass ?event= before trusting absent counts — a truncated read under-counts keys and can manufacture "missing" fields that are really a row limit.

Getting told, instead of asking

Everything above is pull. A schema-drift report nobody opens is a report that doesn't exist, so set a webhook and get a daily digest:

curl -X PUT "$BASE/client/v1/settings/contract-alerts?location_id=$LOC" \
  -H "Authorization: Bearer $AK" -H "Content-Type: application/json" \
  -d '{ "webhook_url": "https://hooks.example.com/mythic/contracts",
        "secret": "a-shared-secret-at-least-16-chars" }'

The sweep runs at ~08:00 UTC over the previous whole UTC day — closed, so late-arriving data has landed. With secret set, each POST carries X-Mythic-Signature: sha256=<HMAC-SHA256 of the raw body>.

One webhook per location, not per contract. The most common thing worth alerting about is a tracking outage, and that breaks every contract at once. Per-contract delivery would send thirty alerts for one cause, so the outage case is exactly one payload with type: "contracts.tracking_outage".

It only fires when something changes. A violation that persists for a month is one alert, not thirty; the recovery is one more (type: "contracts.recovered"). Delivery has to return 2xx for that state to advance, so a failed POST retries on the next sweep rather than being lost.

Three things deliberately never alert, because each is a property of how the data was read rather than a problem with it: sampled_out, source_disagreement, and a truncated property scan. no_events_at_all doesn't alert per-contract either — it becomes the single outage payload instead.

Declaring how much traffic an event should have

A contract says what an event carries. min_daily_events says how often it should arrive:

curl -X PUT "$BASE/client/v1/contracts?location_id=$LOC" \
  -H "Authorization: Bearer $AK" -H "Content-Type: application/json" \
  -d '{ "event": "order_completed",
        "properties": { "value": "number" },
        "min_daily_events": 100 }'

Below it, the report adds a low_volume violation. That one integer is why there is no volume-baseline machinery here — comparing an event against its own recent behaviour exists to infer an expectation, and a contract already states one.

The volume check needs the unsampled reference count and an exact whole-day window (both date_from and date_to). Checked against a sampled scan it breaches on healthy traffic; checked against the default window it breaches at 01:00 UTC and passes at 23:00, because that window is 24–48 hours wide depending on when you ask. Missing either, the check is skipped and says so in the event's warnings — never as a violation, because a configuration gap must not read as broken data. The daily sweep always uses an exact single day, so it is unaffected.

Contract types

string, number, boolean, object, array.

null is not a declarable type — it is an observed state, reported as null_values. Types are the JSON types actually on the wire, so a numeric field sent as "24.99" is a type_mismatch, which is the point.

Contracts are intentionally not full JSON Schema: a property is { type, required }, which is what can be checked as counts across every event in a window. oneOf, pattern, and minimum are not supported.