Skip to main content

Sign CDP ingest requests without a client SDK

The event-ingest gateway at /cdp/v1/:ingest_id/* (track, identify, page, screen, group, alias, and batch) accepts HMAC-signed requests only. API keys, browser sessions, and bearer tokens are rejected on this surface by design — nothing except a valid signature from a tenant-scoped ingest secret gets through. This guide shows how to mint the secret, exactly which bytes to sign, and how to produce the three signing headers in Node, Python, and Go using nothing but the standard library. Reach for raw HTTP when the web and Node SDKs do not fit — a Go service, a serverless function, an edge worker, or an embedded environment. If you are already on Node, the CdpAnalyticsClient in @devotel-orbit/node does this signing for you; everything below is what it does under the hood.

1. Mint the ingest secret and read the ingest id

The ingest secret identifies your workspace to the gateway. Two ways to mint one:
  • Dashboard. Open Integrations → CDP → Sources, create a source, and copy the secret shown once. The source’s ingest id sits next to it.
  • API. POST /api/v1/cdp/secrets against your workspace’s API key:
The response returns the plaintext secret exactly once — store it immediately:
  • id is the ingest id — paste it into the request URL as /cdp/v1/{id}/track. It is public-safe: it routes your request to your workspace but cannot sign anything on its own.
  • plaintext is the signing key. Only the 6-character prefix is retrievable afterwards (GET /api/v1/cdp/secrets); a lost secret has to be replaced, not recovered.
  • The plaintext value goes into an environment variable or your secrets manager. Keep it server-side, always. A browser that can read the secret can sign events as anyone, so client-side JavaScript must never see it. Browser eventing uses the public-key Web SDK instead (see section 6).

2. The wire contract

Every request to /cdp/v1/:ingest_id/{track|identify|page|screen|group|alias|batch} carries three headers: The canonical string the signature covers is exactly three segments joined with periods:
where <raw body> is the exact UTF-8 bytes of the JSON body you send — no reformatting, no key reordering, no whitespace change between what you sign and what goes over the wire. Two production rules follow from this:
  1. Serialize the body once, sign those exact bytes, and send those exact bytes. Never let a framework re-serialize between the two steps.
  2. Sign the string concatenation above with the secret as the HMAC key, then lowercase-hex encode the digest.
GET requests do not exist on this surface — only POST.

3. A runnable signing loop in three runtimes

Each example below builds one track call end to end. The variable names match the table above so you can diff the three against each other.

Node (18+)

Python (3.9+)

Note the Python example signs the byte concatenation timestamp + "." + nonce + "." + body — in the Node example the same join happens inside one string. Both end at the identical canonical bytes the gateway rebuilds, so keep whichever shape is cleaner in your runtime.

Go (1.20+)

A useful sanity check before you go live: sign one payload and compute the expected digest locally, then compare against what your gateway call returns — a 401 at this point means the canonical bytes differ (re-serialization or a header character-encoding mismatch), not that the endpoint is wrong.

4. The same loop for /identify, /batch, and /alias

Only the URL and the JSON body change — the signing loop is byte-identical across all seven endpoints. Wrapping the steps above into a helper (sign(secret, timestamp, nonce, body) → headers) is the entire integration. /identify upserts the contact and folds traits into the profile. Send it once when the visitor first authenticates, and pass the anonymousId your client captured before login so the pre-auth events recorded under that id are backfilled onto the contact:
The 200 response adds contact_id and contact_created to the standard received / event_id envelope. /batch flushes up to 200 events in one POST, Segment-style. Items can mix track, identify, page, screen, group, and alias; a failing leg never fails the whole batch:
The response returns count and a per-leg results array — inspect results[i].ok to retry just the legs that failed. /alias folds an anonymous id into a known user id, merging two contact profiles when both exist. Use the alias shown above — userId is the surviving id, previousId is the one being folded in. Typical sequence: your site assigns an anonymousId on first visit, the visitor later signs up, you fire identify plus alias with previousId set to that anonymous id, and the pre-signup event history attaches to the customer’s profile. The response adds merge_action (merged, relinked, or noop) and the two target_contact_id / source_contact_id fields so you can confirm the fold landed.

5. Expected responses and failure modes

A request that passes the signature gate returns HTTP 200 with the standard envelope — the data block varies slightly by endpoint:
  • deduped: true comes back when you re-sent a messageId you already used (Segment IDs are idempotency keys): the event was accepted but not stored a second time. If you do not set messageId, the server derives one from the payload and a minute bucket so network-blip retries stay safe — pass your own if your retry loop spans longer than a minute.
  • contact_id is null on track / page / screen / group when neither userId nor anonymousId resolved to a known contact.
Auth failures are 401 with a single shape regardless of which check tripped — the gateway never leaks whether the timestamp, nonce, or signature failed:
Common causes, in order:
  1. Re-serialized body. The canonical string used the pre-encoding form; the gateway signs the wire bytes. Sign exactly what you POST.
  2. Clock skew past 5 minutes. Sync the sender to NTP and send Unix seconds, not milliseconds.
  3. Nonce reuse. Generate 16 random bytes per request — do not use a counter across processes.
  4. Missing v1= prefix. The signature header is v1=<hex>, not a bare hex string.
  5. Larger than 1 MB body. Split into /batch legs; the gateway returns 413 on oversized payloads rather than truncating.
Two further 422 classes exist past the signature gate, returned in the bodies when the event itself fails a contract:
  • SCHEMA_VALIDATION_FAILED — a workspace-authored event schema ran in strict mode and rejected the payload. The response body carries error.validation_errors, an array of { path, message, expected, received } entries naming the offending property.
  • TRACKING_PLAN_VIOLATION — a workspace-authored tracking plan in strict enforcement rejected the event. The body carries error.details.violations with type, path, expected, and actual per violation. Start tracked events in soft mode — violations log without rejects — and flip to strict only when your producers are clean.

6. When to use a hand-rolled signing loop

Use raw HTTP + this guide when the Node SDK does not reach your runtime, when you need exact control over flush cadence, or when auditing requires the signing happen in-process rather than inside a third-party dependency. Prefer the SDK surfaces when you have a choice:
  • Node services@devotel-orbit/node’s CdpAnalyticsClient wraps this exact loop plus queueing and flush-on-exit.
  • Edge / SSR surfaces with a trusted secret — the Web SDK OrbitCdpAnalytics (and its queued variant OrbitCdpAnalyticsQueue) signs with WebCrypto where the secret can stay in the server’s environment.
  • Pure browser eventing — never a signed-ingest story. The ingest secret is server-only by design; browser clients go through the public-key surface that carries no signing secret at all.

See also

  • CDP event model — the shape of the events these calls persist
  • CDP tracking plan — declare the contract that rejects or logs drift in producer payloads
  • CDP API reference — full parameter lists for every ingest endpoint
  • Web SDK — the OrbitCdpAnalytics client that performs this signing server-side for you