Skip to main content

Troubleshooting: TRACKING_PLAN_VIOLATION on CDP ingest

A signed-HMAC call to /cdp/v1/{ingest_id}/{track|page|screen|group|batch} came back 422 TRACKING_PLAN_VIOLATION. The signature was fine — your workspace’s tracking plan owns this gate, and the plan rejected the event itself. This page decodes which plan rule fired, how to decide between relaxing the plan and fixing the event, and what not to retry. The same class of failure in soft mode is invisible here — soft-mode violations log without rejects, so this page only has something to say once your plan flips an event to enforcement=strict. For the plan mechanics, see the CDP tracking plan guide; for the signing contract that gets you past the 401 gate first, see CDP ingest signing.

1. Confirm it is the plan, not the signature or the schema

Three rejection shapes live on the ingest surface, and each has a different owner:
  • 401 CDP_UNAUTHORIZED — the HMAC check failed (signature, timestamp skew, nonce reuse). That is a transport problem; fix the signing loop, not the payload. See CDP ingest signing.
  • 422 SCHEMA_VALIDATION_FAILED — a workspace-authored event schema in strict mode rejected the payload’s JSON shape. The error.validation_errors array carries { path, message, expected, received } per failure.
  • 422 TRACKING_PLAN_VIOLATION — the tracking plan rejected the event. The error.details.violations array tells you which rule.
Read error.code before anything else. Only the third one is a plan decision; the other two never reach the plan.

2. Read details.violations — the rule that fired

The envelope names the event and lists every rule it broke:
Each violation gives type, path into the payload, the expected shape, and the actual value that arrived. The same rows land in the violations feed, so you can page back through a burst: GET /api/v1/cdp/tracking-plan/violations?event_name=<name>&enforcement=strict. The dashboard view is Integrations → CDP → Tracking Plan → Violations.

3. Which rule fired — the five violation types

The plan’s strict mode fires these in order; the type field tells you which one tripped:
  • missing_required — a property in the plan’s required list arrived missing or null. The producer stopped sending it (or renamed it).
  • type_mismatch — the value’s type differs from the plan’s rule (integer satisfies a number rule).
  • enum_mismatch — the value fell outside the plan’s enum set.
  • extra_property — a property arrived that the plan does not declare (only flagged once the schema declares at least one property).
  • unknown_event — the event name has no plan row. This one is recorded soft and accepted, so it never causes a 422 — it shows up in the feed so SDK drift is visible without costing events.
A missing_required on every event of a type almost always means a producer version stopped sending the property. An extra_property means the producer sends something the plan does not yet declare.

4. The switch — your plan runs enforcement=strict

The tracking plan is a tenant-owned policy gate: you declared the event contract, and you chose strict on that event’s row. The platform enforces your choice — it cannot override it, and retrying the same bytes changes nothing. Decide among three fixes:
  1. Fix the producer to match the plan. The payload drifted; correct the SDK and re-send. This is the right path when the plan reflects the contract you actually want.
  2. Amend the plan. The new shape is legitimate (a renamed property, a new optional field, a wider enum). Update the row with POST /api/v1/cdp/tracking-plan — it upserts by event_name — or from Integrations → CDP → Tracking Plan in the dashboard. Add the property rule, widen the enum, or drop the property from required.
  3. Drop the row from strict to soft. If a producer you cannot force-update (a mobile SDK on an old version) is the one violating, flip the event back to enforcement=soft. Violations keep logging, the events are accepted, and you stop the data loss while you fix the producer. New rows should start soft for exactly this reason.
Pick (1) or (2) deliberately; falling back to (3) permanently means the gate no longer gates.

5. Worked example — reproduce and resolve

Send a signed track whose payload violates the plan (the full signing loop is in CDP ingest signing):
The 422 envelope comes back shaped like section 2. Take the path and actual from details.violations straight into the violations feed query, fix per section 4, then re-send the corrected bytes. The rejected messageId is never consumed — a corrected retry with your own messageId is accepted cleanly.

6. What not to retry

  • Do not retry the same payload. A plan violation is a deterministic tenant-owned decision — no backoff window, retry budget, or queue will change the verdict. A retry loop here just burns events against the same rule.
  • Do not drop the events to a dead letter and “fix later”. Strict rejection means the event is gone; fix the producer or the plan first, then re-send.
  • Do not weaken the plan to soft silently as the permanent fix. That trades a visible 422 for invisible drift. If you flip to soft, track the violations feed until the producer is clean, then flip back.
  • Do not ask Support to relax the gate for you. The plan and its enforcement level are tenant-owned policy — the platform cannot override them, by design.

7. Support bundle — what to send when you escalate

If the violations feed disagrees with what your producer claims to send (suspected parse-time mutation, a proxy re-serializing properties), open a ticket with:
  • the ingest id from the request URL (cdpsec_...),
  • the plan row id (ctp_evt_...) and the enforcement it ran,
  • a violations-feed row (ctp_vio_...) or the raw 422 envelope with meta.request_id,
  • the exact event body as sent over the wire (before any SDK re-serialization).
That is enough for Support to tell whether the divergence is on the wire or in the plan row itself.

See also