Hogsend is brand new.Try it
Hogsend
Data API

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"
}
FieldTypeRequiredDescription
namestringYesEvent name (this is the journey trigger; replaces the old event field)
emailstringone of email/userIdRecipient email (normalized)
userIdstringone of email/userIdYour external user identifier
anonymousIdstringNoYour 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.
eventPropertiesRecord<string, unknown>NoStored on the event row; what trigger.where and exitOn evaluate. Does not touch the contact.
contactPropertiesRecord<string, unknown>NoMerged onto contacts.properties. Does not touch the event. What buckets segment on.
listsRecord<string, boolean>NoList membership applied after ingest (requires a resolvable email)
valuenumberNoThe 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
currencystringNoISO-4217 alpha code for value (3 letters, uppercased at ingest). Ignored without value
idempotencyKeystringNoDedup key. A replay within the window returns stored: false and does not re-ingest
timestampstringNoISO 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:

  • eventProperties describe what happened. They live on user_events and feed Hatchet, so a journey trigger.where or exitOn rule sees them. They never reach the contact.
  • contactProperties describe 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 202 with stored: true.
  • A duplicate is a no-op. Any later request carrying a key already seen returns 202 with stored: 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_123 that 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
    }
  ]
}
FieldTypeDescription
storedbooleanfalse when a duplicate idempotencyKey was already seen (no re-ingest)
exitsExitResult[]Every active journey state evaluated against exitOn; exited: true means this event removed the user from that journey
listsErrorstring (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

StatusMeaning
400Neither email nor userId supplied
401Missing/invalid key
403Key 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": [] }