Skip to main content

HTTPS ingestion endpoint

POST /v1/events on the dedicated ingestion origin — the streaming front door for your events. Use it directly from any language that can send JSON over HTTPS.

Planned clients of this endpoint: the Python and TypeScript SDKs, the Kafka and Kinesis bridges, and the Segment and RudderStack destinations.

Prerequisites​

  • An API key with the ingest scope (tsk_… server-side; browser/mobile clients use publishable tpk_… keys instead).

Create the credential​

Your API key is the credential. For untrusted clients, mint a publishable key — ingest-only, single project, with allowed origins — never ship a secret key to a browser.

The batch contract​

One request carries up to 500 events / 5 MiB. Results are per-item: a malformed event rejects alone, the rest of the batch lands.

01-send-events.sh
#!/usr/bin/env bash
# POST a batch to the ingestion endpoint and read the per-item results.
set -euo pipefail

: "${TESSERA_API_BASE:?e.g. https://api.staging.tessera.zone/v1}"
: "${TESSERA_INGEST_BASE:?e.g. https://ingest.staging.tessera.zone/v1}"
: "${TESSERA_API_KEY:?your tsk_test_… key}"

NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

# One request, up to 500 events / 5 MiB. The response is per-item: a batch
# with one bad event still lands the other 499.
RESULT="$(curl -sS -X POST "$TESSERA_INGEST_BASE/events" \
-H "Authorization: Bearer $TESSERA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: docs-http-ingestion-batch" \
-d '{
"events": [
{
"event_id": "http-0001",
"entity": {"type": "device", "id": "d_1001"},
"event_type": "sensor.reading",
"timestamp": "'"$NOW"'",
"attributes": {"temperature_c": 21.4, "battery_pct": 88}
},
{
"event_id": "http-0002",
"entity": {"type": "device", "id": "d_1001"},
"event_type": "sensor.reading",
"timestamp": "'"$NOW"'",
"attributes": {"temperature_c": {"nested": "objects are rejected"}}
}
]
}')"
printf '%s\n' "$RESULT" | python3 -m json.tool

# Acceptance means durably enqueued; the good event appears on the timeline.
for _ in $(seq 1 30); do
COUNT="$(curl -sS "$TESSERA_API_BASE/entities/device/d_1001/events" \
-H "Authorization: Bearer $TESSERA_API_KEY" \
| python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"]))')"
if [ "$COUNT" -ge 1 ]; then break; fi
sleep 1
done
echo "timeline events for d_1001: $COUNT"

Download connect/http-ingestion/01-send-events.sh — this exact file runs in CI on every change (hosts shown are for this environment).

Three properties to build against:

  • Acceptance = durably enqueued. The 200 means your events are in the pipeline, not that contract checks ran — those happen downstream.
  • Idempotency, twice. Supply event_id per event (retries of the same fact are no-ops forever) and Idempotency-Key per request (network-level retries of the whole batch dedupe for 24 h).
  • Order comes from timestamp. Send when convenient; each entity's history is ordered by event time, not arrival time.

Choosing event_id values​

The ledger places an event by its entity, its microsecond timestamp and the first 16 characters of event_id (spec 002 §8), and identifies it by its full event_id.

Make the first 16 characters of every event id unique: start it with a random or hashed part (for example a UUID or a hash), not a fixed prefix such as a date or an order-number stem. Until the ledger identity upgrade, an event whose first 16 characters match another event of the same entity at the same timestamp is not stored when it arrives: it is held back and is missing from timelines, the event store and scoring until the upgrade stores it. Resending it with the same id does not change that; a resend of the same event (same full event_id) is always a harmless duplicate.

This matters most when many events of one entity share a timestamp: exports and backfills with date- or second-resolution timestamps, combined with structured ids such as {user_uuid}-{n} or order-2026-10- followed by an order number, whose distinguishing part sits after the first 16 characters. Ids the platform derives (when you omit event_id) are hash digests and are not affected.

The first 16 characters of event_id must not contain #. POST /events rejects such an item with invalid_argument and the message event_id: must not contain '#' in its first 16 characters; the rest of the batch is accepted. Where the id comes from a mapping, what happens depends on the transport:

  • Webhook sources and the Pub/Sub intake report a mapping failure in the dead-letter queue. Fix the id or the mapping, then replay: the replay re-runs the mapping.
  • Raw JSON sources (automatic onboarding) quarantine the record with the reason normalization_failed, and the source's exceptions say A raw record remains quarantined: event_id must not contain '#' in its first 16 characters. A quarantined record is never ingested, and re-sending the same batch does not change that. To recover, send the records again with corrected ids in a new batch. If sampled records show the problem during onboarding, the source asks for attention instead; fix the ids, or leave the event-key field unbound so ids are derived. See raw JSON receipts.

A # after the 16th character is accepted.

Entity ids​

An entity id (entity.id, and every secondary_entities[].id) must not contain # anywhere: it separates the segments of the platform's storage keys. POST /events rejects such an item with invalid_argument and the message entity.id: must not contain '#' (or secondary_entities.{i}.id: must not contain '#'); the rest of the batch is accepted. Where the id comes from a mapping, each transport handles it as it handles a # in the event_id key segment above: webhook and Pub/Sub sources report a mapping failure in the dead-letter queue, which you replay after the fix; raw JSON sources quarantine the record with normalization_failed, name the cause in the source's exceptions and flag sampled ids during onboarding (see raw JSON receipts); file and warehouse syncs reject the row with event_validation_failed, naming the entity id column. When a source has an entity-key crosswalk, the id it produces is what counts: a raw id with # that the crosswalk rewrites is accepted, and a new crosswalk version cannot map to an id with #. To recover, resend with a corrected entity id; refused records are never ingested. Entities stored before this rule keep reading, scoring and erasing.

Dead letters​

Events that pass shape validation but violate your schema contract at stream time (an excluded event type, for example) park in the project's dead-letter view instead of vanishing:

curl -H "Authorization: Bearer $TESSERA_API_KEY" "$TESSERA_API_BASE/events/deadletter"

Fix the cause (usually: approve a new contract version), then POST /events/deadletter:replay — nothing is lost while you iterate.

Items with reason: schema_invalid are not replayable: the producer must resend a corrected event. One exception carries the same reason: an accepted, valid event the stream router could not finish within its delivery attempts. Its detail starts with subscription_max_delivery_attempts: and its stored event has a router_forwarded member; replay it as is (an event that had already landed is recognized as a duplicate). When you filter by reason=schema_invalid you see both kinds.

Check that events are arriving​

Each API key sends to its own stream source, named sdk: plus the key prefix. The sdk: prefix is a fixed name used for every API key's source, whatever client sends the events. Find its source_id with GET /sources, then read when it last accepted a batch and how many events it has accepted. This read needs a key with the read scope; publishable tpk_ keys can only ingest.

curl -H "Authorization: Bearer $TESSERA_API_KEY" "$TESSERA_API_BASE/sources/$SOURCE_ID/canonical-activity"

last_received_at is the server time at which the newest batch was accepted and accepted_events counts events accepted since tracked_since. Accepted means durably queued, not yet interpreted or scored, and a retry sent without an Idempotency-Key counts again. A source with no recorded batches returns null times and a count of 0, and traffic accepted before tracking began is not counted.

Troubleshooting​

SymptomCauseFix
401 unauthenticatedwrong originingestion has its own base URL (ingest.…), not the API origin
429 resource_exhaustedper-key event rate exceededhonor retry_after_seconds
item rejected attributes.…: nested objectnested JSON in attributesflatten upstream (item.sku → item_sku)
events accepted but missing from timelinesdead-lettered downstreamcheck GET /events/deadletter for the typed reason