Events
POST /v1/events — the journey trigger. The canonical replacement for the old /v1/ingest, with the contactProperties / eventProperties split.
POST /v1/events is the heart of the Data API. It ingests an event, which: stores the event row, merges contact properties onto the contact, pushes to Hatchet for journey routing, processes exit conditions on the user's active journeys, and (optionally) applies list membership.
This endpoint is the canonical replacement for the deleted /v1/ingest. The old route used a single properties bag and a required userId; it has been removed with no compatibility shim. Move to POST /v1/events with name (not event), the two-bag property split, email or userId, and an ingest-scoped key.
Requires a bearer key with the ingest scope.
Request
One of email or userId is required.
{
"name": "signup",
"email": "ada@example.com",
"userId": "user_123",
"anonymousId": "01890a5d-...", // your analytics anon id, for zero-merge identity stitching
"eventProperties": { "source": "web" }, // → the event row + journey trigger.where / exitOn
"contactProperties": { "plan": "pro" }, // → the contact record only
"lists": { "product-updates": true },
"value": 149.00, // first-class monetary worth (revenue spine)
"currency": "GBP",
"idempotencyKey": "evt_signup_user_123",
"timestamp": "2026-01-15T10:30:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Event name (this is the journey trigger; replaces the old event field) |
email | string | one of email/userId | Recipient email (normalized) |
userId | string | one of email/userId | Your external user identifier |
anonymousId | string | No | Your analytics provider's anonymous id (e.g. the browser's posthog-js distinct id). Not a third identity — email or userId is still required. When sent before any userId/external id exists, the contact key becomes this id, so the browser's own identify(<contactKey>) and the server's events land on the same PostHog person with no merge call. A later userId/external id flips the canonical key and fires exactly one $create_alias to absorb the anonymous person. |
eventProperties | Record<string, unknown> | No | Stored on the event row; what trigger.where and exitOn evaluate. Does not touch the contact. |
contactProperties | Record<string, unknown> | No | Merged onto contacts.properties. Does not touch the event. What buckets segment on. |
lists | Record<string, boolean> | No | List membership applied after ingest (requires a resolvable email) |
value | number | No | The event's monetary worth (order total, deal value). First-class — lands on user_events.value, feeding revenue rollups and conversion points; not a property. Must be finite |
currency | string | No | ISO-4217 alpha code for value (3 letters, uppercased at ingest). Ignored without value |
idempotencyKey | string | No | Dedup key. A replay within the window returns stored: false and does not re-ingest |
timestamp | string | No | ISO 8601. Backdates user_events.occurred_at for backfill / replay (defaults to now) |
The property split
This is the most important thing to internalize about events:
eventPropertiesdescribe what happened. They live onuser_eventsand feed Hatchet, so a journeytrigger.whereorexitOnrule sees them. They never reach the contact.contactPropertiesdescribe who the person is. They merge onto the durable contact record. Buckets and contact-state conditions see them. They never reach the event.
This is a deliberate split — and a behavior change from the old single-properties /v1/ingest. A journey that used to read a contact attribute off the event payload must now key its trigger.where on an eventProperty, and a bucket must read contact state. See Identity for the merge semantics.
Idempotent ingest
Ingest is exactly-once per key. Supply an idempotency key and a redelivery of the same event is a safe no-op — it never re-enrols journeys, never re-pushes to Hatchet, and never doubles a revenue value.
Supplying the key
You can supply the key either in the body (idempotencyKey) or as an Idempotency-Key HTTP header. The header wins when both are present.
curl -X POST http://localhost:3002/v1/events \
-H "Authorization: Bearer $HOGSEND_DATA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: evt_signup_user_123" \
-d '{ "name": "signup", "userId": "user_123" }'The contract
- First write wins. The first request for a given key is stored, routed to Hatchet, and exit-checked as normal — the response is
202withstored: true. - A duplicate is a no-op. Any later request carrying a key already seen returns
202withstored: false. The event is not re-stored, journeys are not re-triggered or re-enrolled, exit conditions are not re-evaluated, and the contact is not re-touched. Nothing downstream fires a second time. - The key is the whole identity of the event. Two genuinely distinct events must carry distinct keys; the same key always collapses to one ingest.
{ "stored": false, "exits": [] }The dedup is enforced by a unique index on user_events.idempotency_key, so it holds even under a concurrent retry storm — exactly one request wins the insert and the rest observe stored: false. If the durable Hatchet push fails after the row is written, the row is rolled back so the key stays reclaimable on a genuine retry (a transient failure never permanently swallows the key).
Deriving a good key
Derive the key from a stable id that the upstream system already assigns to this exact event — a provider event id is ideal, because the provider reuses it verbatim on every at-least-once redelivery:
- A Stripe webhook → the Stripe event id (
evt_…). - An Intercom/Fin webhook → the notification id (
intercom:<id>). - Your own producer → a deterministic composite like
signup:user_123that you can regenerate on a retry.
Avoid keys that change on retry (a fresh UUID per attempt, Date.now()) — they defeat dedup, because the retried request looks brand new. All built-in webhook presets already set a redelivery-safe key for you, so pointing Stripe/Intercom/Segment at Hogsend gets exactly-once ingest with no configuration.
Response 202
{
"stored": true,
"exits": [
{
"journeyId": "onboarding-welcome",
"stateId": "550e8400-e29b-41d4-a716-446655440000",
"exited": false
}
]
}| Field | Type | Description |
|---|---|---|
stored | boolean | false when a duplicate idempotencyKey was already seen (no re-ingest) |
exits | ExitResult[] | Every active journey state evaluated against exitOn; exited: true means this event removed the user from that journey |
listsError | string (optional) | Present only when the durable ingest succeeded but the (non-atomic, post-ingest) list write failed |
Why a list failure is not a 400
List membership is written after the event is durably ingested. The event store, Hatchet dispatch, and exit processing have all already succeeded by then. If the list write fails, returning a 400 would (a) hide a successful ingest behind a "nothing happened" status and (b) tempt a retry that double-ingests the event. Instead the response stays 202 and surfaces a non-fatal listsError warning:
{ "stored": true, "exits": [], "listsError": "Contact has no email; cannot manage list membership" }Errors
| Status | Meaning |
|---|---|
400 | Neither email nor userId supplied |
401 | Missing/invalid key |
403 | Key lacks the ingest scope |
Example
curl -X POST http://localhost:3002/v1/events \
-H "Authorization: Bearer $HOGSEND_DATA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "user.signed_up",
"email": "ada@example.com",
"userId": "user_123",
"eventProperties": { "source": "landing-page" },
"contactProperties": { "plan": "pro" }
}'{ "stored": true, "exits": [] }