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
ingestscope (tsk_…server-side; browser/mobile clients use publishabletpk_…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.
#!/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_idper event (retries of the same fact are no-ops forever) andIdempotency-Keyper 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 sayA 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
| Symptom | Cause | Fix |
|---|---|---|
401 unauthenticated | wrong origin | ingestion has its own base URL (ingest.…), not the API origin |
429 resource_exhausted | per-key event rate exceeded | honor retry_after_seconds |
item rejected attributes.…: nested object | nested JSON in attributes | flatten upstream (item.sku → item_sku) |
| events accepted but missing from timelines | dead-lettered downstream | check GET /events/deadletter for the typed reason |