Send an event
200 OK. Authenticated with the X-Sender-Tenant and X-API-Key header pair, not a bearer token.
string
required
Your Workspace ID. The workspace slug or the workspace UUID, both accepted.
string
required
A workspace API key (
sk_evt_...).Body
string
required
The event name, 1 to 128 characters. Normalized to
lower_snake_case, so
Quiz Completed, quiz-completed and quiz_completed all resolve to the
same event. The first time a name arrives it is created in your workspace’s
event catalogue.string
A unique id for this occurrence, up to 255 characters. Two deliveries
carrying the same
eventId are counted once. Send one on every call.string
ISO-8601 timestamp of when the event happened. Defaults to the time the
request is received. Use it for any historical import.
object
What happened. Arbitrary key and value pairs describing this occurrence.
Keys are normalized to
lower_snake_case, so orderTotal is stored as
order_total. The whole bag is capped at 32 KB.integer
Monetary value in minor currency units (agorot, cents).
24990 is
249.90. Feeds revenue reports and attribution. Must be a whole number, a
decimal is rejected.string
Currency code for
value, up to 8 characters. For example ILS.object
Profile fields to set on the resolved contact. Validated against your
workspace’s field catalogue. Deliberately separate from
properties.string
Profile first name, up to 255 characters. Written on any resolved contact,
new or existing.
string
Profile last name, up to 255 characters. Written on any resolved contact,
new or existing.
Identity
At least one identifier is required. See How a profile is resolved.string
An existing Senderz profile UUID. The strongest identifier, it wins outright.
string
An id owned by your own system, up to 255 characters. Unique within
externalNamespace.string
Names the system that owns
externalId, up to 64 characters. For example
billing.string
Contact email, up to 320 characters. Must be a valid address.
string
Contact phone, up to 32 characters. Normalized to E.164.
string
A device or browser id, up to 128 characters. Records activity before a
visitor identifies themselves.
Response
string
One of
ok, duplicate, anonymous or no_contact.string | null
The canonical ledger id for this event, for correlating with your own
records. This is the Senderz id, not the
eventId you supplied. null when
nothing was recorded.boolean
true when the supplied eventId had already been seen. Omitted when the
request carried no identifier at all.string | null
The resolved or created profile UUID.
null for anonymous and
no_contact.boolean
true if this event created the profile.integer
How many automation enrolments were dispatched while handling this request.
Downstream effects are drained per workspace, so this can include a small
number of enrolments left pending by an earlier call.
Example
How a profile is resolved
Identifiers are tried in a fixed order. The first one that matches an existing profile wins.1
contactId
An internal profile UUID wins outright, before anything else is consulted.
2
externalId within externalNamespace
An id set deliberately by the system that owns the customer record.
3
Lowercased and trimmed before matching.
4
phone
Normalized to E.164 before matching.
5
anonymousId
A device or browser, matched last.
When a profile is created
If nothing matches, a profile is created only when the event carries anemail or a phone. It is created with source api, and any contact_created automation fires for it.
Anonymous activity
An event carrying ananonymousId and nothing else is recorded against that id and returns status: "anonymous" with contactId: null. Send the same anonymousId together with an email or phone once the visitor identifies themselves, and the earlier anonymous events are attached to the profile at that moment. Their history is preserved rather than starting from the moment of signup.
Deduplication
With aneventId, deduplication is exact. Two deliveries carrying the same id are one event: the second returns status: "duplicate", writes nothing, and fires no automation. Two different ids are two events even when every other field matches, so a customer who buys the same item twice is counted twice.
Without an eventId, the event falls back to a per contact, per event name, per minute window. A burst of identical calls collapses into one, which also means two genuine repeats inside the same minute collapse into one and are silently lost. This fallback exists so integrations built before eventId keep working, not as a recommended mode.
Use something your own system already treats as unique. An order id, an invoice id, or a composite like sub_8842:renewal:2026-08-26 all work.
Event names and property keys
Both are normalized on the way in.
Normalizing means
orderTotal and order-total never become three separate properties. It also means the key you reference later, in a segment filter or a merge tag, is the normalized one.
Property types are fixed on first sight
The first value a property carries decides its type:boolean, number, string, datetime, list or object. A string that looks like an ISO date is recognized as datetime.
A later value of an incompatible type is rejected with 400 and code metric_property_type_conflict rather than being written where no filter would match it. Widening from datetime to string is allowed; narrowing is not. A number sent as the string "49.90" stays a string, it is never coerced, so send numbers as JSON numbers.
Revenue
value is a whole number of minor currency units, paired with currency. Send 24990 and ILS for 249.90 shekels. Sending 249.90 is rejected as a non-integer.
Events carrying a value feed revenue reports and attribution, so an event like subscription_renewed or booking_paid should carry one.
Backdating
occurredAt places the event at the moment it really happened. Any historical import needs it, otherwise every backfilled row lands with today’s timestamp and every time-windowed report and segment reads them as activity from today.
What happens downstream
An accepted event does four things.Ledger entry
Written to the canonical event ledger with its properties, value, currency
and timestamp. This is what segments and reports read.
Timeline entry
Added to the profile’s activity feed as a custom event carrying the event
name and its properties.
Automation enrolment
Enrols any automation whose trigger is a custom event matching this name.
Event catalogue
A new event name appears in your workspace’s event catalogue and in
GET /public/events.Matching an automation trigger
The automation’s configured custom event name is normalized the same way as the incoming one, so an automation built onQuiz Completed in the builder is triggered by quiz_completed sent through this endpoint.
Custom event names appear in the event catalogue returned by
GET /public/events, but they cannot be subscribed to as outbound webhook
events. Outbound webhooks use a fixed dotted
vocabulary such as email.opened.Using event properties in automations
Properties become flat merge tags in automation email and SMS templates. A property stored asorder_total is referenced as {{order_total}}. value is available as {{amount}} and currency as {{currency}} when the properties do not already define those keys.
Only primitive values become merge tags. Nested objects and arrays are skipped, and the identity and compliance tags {{firstName}}, {{lastName}}, {{email}}, {{phone}} and {{unsubscribeUrl}} are reserved and cannot be shadowed by a property of the same name.
See Automations for how to build the trigger.
properties vs profileProperties
The two are separate on purpose.
An event property never becomes a contact field. If you want a value to persist on the person, put it in
profileProperties.
profileProperties, firstName and lastName are written to any resolved profile, new or existing. Every key is checked against your workspace’s field catalogue: an unknown key or a computed field is skipped. If every key in the request is rejected, the call returns 400 with code profile_field_invalid.
Errors
Errors use the standard envelope. See Errors and rate limits.
An event that carried no identifier at all is not an error. It returns
200 with status: "no_contact", so an older integration keeps working rather than starting to fail.
