> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Sign CDP ingest requests without a client SDK

> Build the HMAC-SHA256 signature, timestamp, and nonce headers the CDP ingest gateway requires, with runnable Node, Python, and Go examples that use only the standard library of each runtime.

# 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](/sdks/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:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/cdp/secrets \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

The response returns the plaintext secret exactly once — store it immediately:

```json theme={null}
{
  "data": {
    "id": "cdpsec_01J8Z9K3P4Q5R6S7T8U9V0W1X2",
    "plaintext": "9f8Kx2QpZr7Lm4Tn1Vd6Ws0Yc3Bh5Aj8Ek2Ug4Ol6Qa",
    "prefix": "9f8Kx2",
    "created_at": "2026-08-07T12:00:00.000Z"
  }
}
```

* `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](#6-when-to-use-a-hand-rolled-loop)).

## 2. The wire contract

Every request to `/cdp/v1/:ingest_id/{track|identify|page|screen|group|alias|batch}` carries three headers:

| Header                  | Value                                                                                                                                        |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Orbit-CDP-Signature` | `v1=<lowercase hex HMAC-SHA256 of the canonical string, keyed by the secret plaintext>`                                                      |
| `X-Orbit-CDP-Timestamp` | Unix **seconds** (integer) at send time. The gateway rejects timestamps skewed more than **5 minutes** in either direction (past or future). |
| `X-Orbit-CDP-Nonce`     | A unique-per-request token, 1–200 characters. Reusing a nonce is a replay and fails.                                                         |

The canonical string the signature covers is exactly three segments joined with periods:

```
<timestamp>.<nonce>.<raw body>
```

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+)

```js theme={null}
// cdp-track.mjs — run: node cdp-track.mjs
import { createHmac, randomBytes } from "node:crypto";

const ingestId = process.env.ORBIT_CDP_INGEST_ID; // e.g. cdpsec_01J...
const secret = process.env.ORBIT_CDP_SECRET;      // plaintext from POST /api/v1/cdp/secrets

const body = JSON.stringify({
  event: "Order Completed",
  userId: "user_42f8",
  properties: { order_total: 482.0, currency: "USD" },
});

const timestamp = Math.floor(Date.now() / 1000);        // unix seconds
const nonce = randomBytes(16).toString("hex");          // 32 hex chars
const canonical = `${timestamp}.${nonce}.${body}`;

const signature = createHmac("sha256", secret)
  .update(canonical, "utf8")
  .digest("hex");

const res = await fetch(
  `https://api.orbit.devotel.io/cdp/v1/${ingestId}/track`,
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Orbit-CDP-Signature": `v1=${signature}`,
      "X-Orbit-CDP-Timestamp": String(timestamp),
      "X-Orbit-CDP-Nonce": nonce,
    },
    body,                                              // the exact bytes signed
  },
);
console.log(res.status, await res.text());
```

### Python (3.9+)

```python theme={null}
# cdp_track.py — run: python cdp_track.py
import hashlib
import hmac
import json
import os
import secrets as py_secrets
import time
import urllib.request

ingest_id = os.environ["ORBIT_CDP_INGEST_ID"]
secret = os.environ["ORBIT_CDP_SECRET"]

body = json.dumps({
    "event": "Order Completed",
    "userId": "user_42f8",
    "properties": {"order_total": 482.0, "currency": "USD"},
}, separators=(",", ":")).encode("utf-8")

timestamp = str(int(time.time()))
nonce = py_secrets.token_hex(16)
canonical = f"{timestamp}.{nonce}.".encode("utf-8") + body

signature = hmac.new(
    secret.encode("utf-8"), canonical, hashlib.sha256
).hexdigest()

req = urllib.request.Request(
    f"https://api.orbit.devotel.io/cdp/v1/{ingest_id}/track",
    data=body,                                     # the exact bytes signed
    method="POST",
    headers={
        "Content-Type": "application/json",
        "X-Orbit-CDP-Signature": f"v1={signature}",
        "X-Orbit-CDP-Timestamp": timestamp,
        "X-Orbit-CDP-Nonce": nonce,
    },
)
with urllib.request.urlopen(req) as res:
    print(res.status, res.read().decode())
```

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+)

```go theme={null}
// cdp_track.go — run: go run cdp_track.go
package main

import (
	"bytes"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"os"
	"strconv"
	"time"
)

func main() {
	ingestID := os.Getenv("ORBIT_CDP_INGEST_ID")
	secret := os.Getenv("ORBIT_CDP_SECRET")

	body := []byte(`{"event":"Order Completed","userId":"user_42f8","properties":{"order_total":482.0,"currency":"USD"}}`)

	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
	nonceBytes := make([]byte, 16)
	if _, err := rand.Read(nonceBytes); err != nil {
		panic(err)
	}
	nonce := hex.EncodeToString(nonceBytes)

	canonical := timestamp + "." + nonce + "." + string(body)
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(canonical))
	signature := hex.EncodeToString(mac.Sum(nil))

	req, err := http.NewRequest(
		"POST",
		fmt.Sprintf("https://api.orbit.devotel.io/cdp/v1/%s/track", ingestID),
		bytes.NewReader(body),                     // the exact bytes signed
	)
	if err != nil {
		panic(err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Orbit-CDP-Signature", "v1="+signature)
	req.Header.Set("X-Orbit-CDP-Timestamp", timestamp)
	req.Header.Set("X-Orbit-CDP-Nonce", nonce)

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	payload, _ := io.ReadAll(res.Body)
	fmt.Println(res.StatusCode, string(payload))
}
```

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:

```json theme={null}
{
  "userId": "user_42f8",
  "anonymousId": "anon_71ba",
  "traits": { "email": "jane@globex.com", "plan": "growth" }
}
```

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:

```json theme={null}
{
  "batch": [
    { "type": "track", "event": "Product Viewed", "userId": "user_42f8", "properties": { "sku": "SKU-001" } },
    { "type": "identify", "userId": "user_42f8", "anonymousId": "anon_71ba", "traits": { "email": "jane@globex.com" } },
    { "type": "alias", "userId": "user_42f8", "previousId": "anon_71ba" }
  ]
}
```

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:

```json theme={null}
{
  "data": {
    "received": true,
    "event_id": "evt_01J8Z9K3P4Q5R6S7T8U9V0W1X2",
    "deduped": false,
    "contact_id": "cont_01H9XABC1234"
  },
  "meta": { "request_id": "req_...", "timestamp": "2026-09-02T08:12:00.000Z" }
}
```

* `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:

```json theme={null}
{ "error": { "code": "CDP_UNAUTHORIZED", "message": "CDP ingest authentication failed", "status": 401 }, "meta": { "request_id": "req_..." } }
```

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](/guides/cdp-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](/sdks/web) `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](/concepts/cdp-event-model) — the shape of the events these calls persist
* [CDP tracking plan](/guides/cdp-tracking-plan) — declare the contract that rejects or logs drift in producer payloads
* [CDP API reference](/api-reference/endpoints/cdp) — full parameter lists for every ingest endpoint
* [Web SDK](/sdks/web) — the `OrbitCdpAnalytics` client that performs this signing server-side for you
