Send authoritative orders, refunds, subscriptions and leads from your backend with HMAC-signed requests.
Use the server API for facts your backend knows: paid orders, refunds, subscriptions, fulfillment and qualified leads. Events sent this way have source_type: server and are authoritative for every builtin event, which is what revenue reporting trusts.
In this page
- Server keys
- Request and signing
- Examples
- Idempotency
- Browser and server copies of one event
- Consent on server events
- Response
Server keys
Create a server key on the property the events belong to (pixel:manage; in Kentro GO, the property's Keys tab):
curl -sS https://apis.kentro.live/pixel/v1/properties/$PROPERTY_ID/server-keys \
-H "Authorization: Bearer $KENTRO_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "label": "orders backend" }'
{
"server_key": {
"id": "0192f0c7-3c4d-7e5f-9061-7b8c9d0e1f2a",
"key_id": "sk_4fHq8Lm2Tz9Wb1Xc",
"label": "orders backend",
"status": "active",
"last_used_at": null,
"created_at": "2026-10-09T17:30:00.000Z",
"revoked_at": null,
"secret": "kQ9vX2mT7pL4wR8zN1cB6yH3jF5dS0aG2eU7iO9qW4E"
}
}
key_id(sk_plus 16 characters) identifies the key in requests;secret(32 random bytes, base64url) signs them.- The secret is shown only in this response. Kentro stores it encrypted and cannot show it again; if you lose it, create a new key and revoke the old one.
- Revoke with
DELETE /v1/properties/:id/server-keys/:keyId, where:keyIdis the key'sid(the UUID), not thesk_key id. Revocation reaches every collector instance within about a minute: requests signed with the key then answer401 KEY_REVOKED, and identity tokens it signed stop verifying. - The same secret signs identity tokens for signed-in visitors.
- A
503 SIGNING_UNAVAILABLEanswer means Kentro cannot store secrets at the moment; try again later.
Request and signing
POST https://pixel.kentro.live/v1/server/events
| Header | Value |
|---|---|
Content-Type | application/json. Required: the signature is checked over the raw JSON bytes, which are only captured for this content type. |
X-Kentro-Key-Id | The sk_ key id. |
X-Kentro-Timestamp | Unix time in seconds (digits only). |
X-Kentro-Signature | sha256= followed by the lower-case hex HMAC-SHA256 of timestamp + "." + rawBody, keyed with the secret. |
X-Kentro-Idempotency-Key | Optional, up to 128 characters. Binds the batch to its body for safe retries. |
The body is { "events": [ ... ] } with 1 to 500 envelopes and at most 512 KB (see Events for the envelope). No origin check applies; device_class and bot can be set in context.
Signing rules:
- Serialize the body once, sign that exact string, send that exact string.
- The HMAC key is the secret string as shown (its UTF-8 bytes). Do not base64-decode it.
- The timestamp must be within 300 seconds of Kentro's clock in either direction (
TIMESTAMP_STALEotherwise). - The comparison is constant-time; a wrong or missing signature answers
401 SIGNATURE_INVALID.
Examples
Each example sends one paid order. The three produce identical signatures for identical bodies.
KEY_ID='sk_4fHq8Lm2Tz9Wb1Xc'
SECRET='kQ9vX2mT7pL4wR8zN1cB6yH3jF5dS0aG2eU7iO9qW4E'
BODY='{"events":[{"event_id":"order-1001-paid","event_name":"commerce.order_paid","order_ref":"order-1001","commerce_subject_ref":"cust-42","properties":{"order_id":"order-1001","total":{"amount_minor":4999,"currency":"USD"},"payment_method":"card"}}]}'
TS=$(date +%s)
SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)"
curl -sS https://pixel.kentro.live/v1/server/events \
-H "Content-Type: application/json" \
-H "X-Kentro-Key-Id: $KEY_ID" \
-H "X-Kentro-Timestamp: $TS" \
-H "X-Kentro-Signature: $SIG" \
-H "X-Kentro-Idempotency-Key: order-1001-paid" \
--data-binary "$BODY"
const crypto = require('node:crypto');
const KEY_ID = process.env.KENTRO_PIXEL_KEY_ID; // sk_...
const SECRET = process.env.KENTRO_PIXEL_KEY_SECRET; // shown once when the key was created
async function sendServerEvents(events, idempotencyKey) {
const body = JSON.stringify({ events });
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = 'sha256=' + crypto.createHmac('sha256', SECRET).update(`${timestamp}.${body}`).digest('hex');
const res = await fetch('https://pixel.kentro.live/v1/server/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Kentro-Key-Id': KEY_ID,
'X-Kentro-Timestamp': timestamp,
'X-Kentro-Signature': signature,
...(idempotencyKey ? { 'X-Kentro-Idempotency-Key': idempotencyKey } : {}),
},
body,
});
const result = await res.json();
if (res.status !== 202) throw new Error(`Kentro Pixel ${res.status}: ${result.error}`);
return result; // { receipt_id, results: [{ event_id, status, reason_code? }] }
}
sendServerEvents([
{
event_id: 'order-1001-paid',
event_name: 'commerce.order_paid',
occurred_at: new Date().toISOString(),
order_ref: 'order-1001',
commerce_subject_ref: 'cust-42',
properties: {
order_id: 'order-1001',
total: { amount_minor: 4999, currency: 'USD' },
payment_method: 'card',
},
},
], 'order-1001-paid').then((receipt) => console.log(receipt.results));
import hashlib
import hmac
import json
import os
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
KEY_ID = os.environ["KENTRO_PIXEL_KEY_ID"] # sk_...
SECRET = os.environ["KENTRO_PIXEL_KEY_SECRET"] # shown once when the key was created
def send_server_events(events, idempotency_key=None):
body = json.dumps({"events": events}, separators=(",", ":")).encode("utf-8")
timestamp = str(int(time.time()))
digest = hmac.new(SECRET.encode("utf-8"), timestamp.encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
headers = {
"Content-Type": "application/json",
"X-Kentro-Key-Id": KEY_ID,
"X-Kentro-Timestamp": timestamp,
"X-Kentro-Signature": "sha256=" + digest,
}
if idempotency_key:
headers["X-Kentro-Idempotency-Key"] = idempotency_key
request = urllib.request.Request("https://pixel.kentro.live/v1/server/events", data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(request, timeout=10) as response:
return json.load(response) # {"receipt_id": ..., "results": [...]}
except urllib.error.HTTPError as error:
raise RuntimeError(f"Kentro Pixel {error.code}: {error.read().decode()}") from error
receipt = send_server_events([{
"event_id": "order-1001-paid",
"event_name": "commerce.order_paid",
"occurred_at": datetime.now(timezone.utc).isoformat(),
"order_ref": "order-1001",
"commerce_subject_ref": "cust-42",
"properties": {
"order_id": "order-1001",
"total": {"amount_minor": 4999, "currency": "USD"},
"payment_method": "card",
},
}], idempotency_key="order-1001-paid")
print(receipt["results"])
Retry guidance: on 429 wait for Retry-After; on 500 and 503 retry with backoff using the same event_id values and idempotency key; on 400 and 401 fix the request first.
Idempotency
Two layers protect retries:
- Per event.
event_idis deduplicated per property: re-sending an event that was already accepted answersduplicatefor that event and stores nothing new. Only accepted events use up theirevent_id(a rejected, suppressed or quarantined event does not, so a corrected or consented resend is accepted), and accepted ids are remembered for at least 14 days. Keepevent_idstable across retries and unique per real-world event (for exampleorder-1001-paid); without one, Kentro derives it from a hash of the envelope. - Per batch.
X-Kentro-Idempotency-Keybinds the key to the SHA-256 of the exact request body. The same key with the same body answers the original receipt with"replayed": truewithout evaluating the events again; a replayed receipt carries the first 100 results of the original batch. The same key with a different body answers409 IDEMPOTENCY_KEY_REUSED. A key stays bound to its first body for as long as the property keeps its event history (at least 13 months), so use a new key for every new batch. Keys longer than 128 characters are ignored.
The signature is valid for 300 seconds, so a captured request can be replayed only inside that window, and the two layers above turn such a replay into duplicate results or a replayed receipt.
Browser and server copies of one event
Because duplicates are detected on event_id per property, a browser event and a server event that share an id count once: the copy accepted first is stored and the other answers duplicate. Since SDK 1.2.0, track accepts your own id in refs.event_id (8 to 128 letters, digits, _, ., : or -):
kentroPixel('track', 'commerce.checkout_started', { checkout_id: 'chk-1001' }, { event_id: 'chk-1001-started' });
The first accepted copy wins
A browser copy that arrives first makes the server copy a
duplicate, and the stored event is then not authoritative. Send revenue events (commerce.order_paid, refunds) from your server only, with their own ids.
Consent on server events
Server events go through the same consent decision as browser events. Business records with the functional purpose (commerce.order_*, commerce.return_created, subscriptions, customers, fulfillment and commerce.shipping_cost_finalized) are accepted unless the snapshot says functional: "denied". Events with the analytics or marketing purpose (for example marketing.signup_completed or marketing.lead_qualified) need the consent you hold for that person in the envelope's consent; with the default property settings and no snapshot they are recorded as suppressed. The Sec-GPC header is ignored on this route; consent.gpc is honored. See Consent and privacy.
Response
A processed request answers 202:
{
"receipt_id": "0192f0c8-0b1c-7d2e-9f30-4a5b6c7d8e9f",
"results": [
{ "event_id": "order-1001-paid", "status": "accepted" }
]
}
A batch replay adds "replayed": true. results follows the order of the request:
| Status | Meaning |
|---|---|
accepted | Valid and stored for processing. |
duplicate | An event with this event_id was already received for the property; nothing new was stored. |
rejected | Invalid; not stored. reason_code says why, and the rejection appears in ingestion health. |
quarantined | Sensitive data detected; stored without its properties, never processed. |
suppressed | Consent missing or denied for the event's purpose; recorded minimized, never processed. |
An event sent under a deprecated name also carries recorded_as, the name Kentro recorded it under. Per-event reason codes and request-level errors are listed in Limits and errors.
Use an AI assistant
Build it with an AI agent
Send signed, authoritative events such as paid orders and refunds from a backend to the Kentro Pixel server API. Copy the prompt into Claude Code, Codex, Cursor or another coding agent. The prompt library explains how to run it.
You are sending server-side events from the user's backend to Kentro Pixel: authoritative facts such as commerce.order_paid and refunds. Revenue reporting trusts these facts only when they come from a server source.
Read first. Fetch https://docs.kentro.io/llms.txt and these pages:
- https://docs.kentro.io/docs/ai-agent-instructions.md (rules you must follow)
- https://docs.kentro.io/docs/kentro-pixel.md
- https://docs.kentro.io/docs/pixel-server-api.md
- https://docs.kentro.io/docs/pixel-events.md
- https://docs.kentro.io/docs/pixel-consent-privacy.md
Ask the user for these before writing code:
1. The backend language and framework, and where payment success and refunds are recorded.
2. A server key for the Pixel property, created in Kentro GO on the property's `Keys` tab (or with POST https://apis.kentro.live/pixel/v1/properties/:id/server-keys). The secret is shown once. Ask the user to store the key id (sk_...) as KENTRO_PIXEL_KEY_ID and the secret as KENTRO_PIXEL_SECRET in the environment, never in the chat or the browser.
3. The collector: https://pixel.kentro.live for production or https://pixel.kentro.dev for Kentro staging.
4. Which business events to send, mapped to event names from the Events page.
Rules:
- POST each batch to `<collector>/v1/server/events` with Content-Type: application/json and three headers: X-Kentro-Key-Id with the key id; X-Kentro-Timestamp with the current Unix time in seconds; X-Kentro-Signature with sha256= followed by the hex HMAC-SHA256 of `"<timestamp>.<raw body>"` keyed with the secret. Sign the exact bytes you send: serialize the body once.
- The timestamp must be within 300 seconds of Kentro's clock, or the request is refused with TIMESTAMP_STALE. A wrong signature is refused with SIGNATURE_INVALID.
- Give every event a stable event_id derived from the business fact (for example the order id plus the event name), so a retry is deduplicated. Kentro deduplicates event ids for at least 14 days. Never generate a new id for a retry.
- event_name is lowercase and dotted (commerce.order_paid). occurred_at is ISO-8601, no more than 7 days in the past or 5 minutes in the future. Each event stays under 32 KB.
- Money follows the event schema in integer minor units; floating-point money is rejected. Read the schemas from GET https://apis.kentro.live/pixel/v1/event-schemas and check events with POST /v1/event-schemas/validate before sending.
- Send consent and identity fields as the Server API and consent pages describe, and never send personal data the schema does not ask for.
- Send after the database commit, from a queue or outbox, so a crash neither loses nor double-sends an event. Retry network errors, 429 and 5xx with backoff and the same event_id.
Steps:
1. A signer module, with a unit test against a fixed timestamp, body and secret.
2. A sender that batches events, posts them, and logs the receipt_id and each event's status.
3. Hooks on payment success and refunds that enqueue the events.
4. A dead-letter path for events that keep failing, with their reason codes.
Acceptance checks. Run each one against Kentro staging or a test property and show the user the requests and responses, with secrets redacted:
- One signed commerce.order_paid event answers 202 with results[0].status accepted.
- The same event sent again answers status duplicate and creates no second record.
- A request whose timestamp is 10 minutes old is refused with TIMESTAMP_STALE, and one with a wrong signature with SIGNATURE_INVALID.
- The event appears in the property's `Debugger` tab with source_type server.
- No server secret appears in code, logs or client assets.

