Product events
Record what someone did in your product, resume the campaign steps waiting for it, and feed goals and acquisition reports.
A product event is your app telling SendSets that someone did something: trial.started, report.opened, plan.upgraded. Events do three jobs:
- a
wait_for_eventstep holds a contact until a named event arrives for them (or its timeout passes), so a sequence can follow what the person did instead of a calendar - goals turn matching events into signups, activations, paid customers, and revenue
- acquisition reports trace those conversions back to confirmed sends
Raw events are kept for the engagement-event retention window (365 days by default). Conversions and daily event counts derived from them are kept after the raw events are pruned.
Send a product event
POST /events
Auth: Scope WRITE_EVENTS · Org permission send_campaigns · Idempotency-Key required
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Dotted lowercase words: [a-z0-9_-]+(\.[a-z0-9_-]+)*, up to 120 characters. |
email | string | one of | The contact's email. The contact must exist. |
contact_id | string (UUID) | one of | The contact's id. The contact must exist. |
identity | object | one of | Your app's identifiers for the person. Exactly one of email, contact_id, or identity. |
identity.user_id | string | Your app's stable user id. Printable, no spaces, up to 200 characters. | |
identity.email | string | The person's email. Matches a contact when one has that address. | |
identity.anonymous_id | string | A pre-signup id, such as the one your frontend stores. | |
identity.account | object | { "id", "name", "domain" } for the customer account the person belongs to. Groups people; never merges them. | |
occurred_at | string (RFC 3339) | no | When it happened. Default: now. At most a day in the future. A wait only matches events that occurred after the contact entered it. |
data | object | no | Event properties, up to 64 KiB. Becomes .App.<output_key> of the wait step that consumed it. |
Identity mode
With email or contact_id the event belongs to that contact, and an unknown contact is refused. With identity, the event is always stored, and SendSets resolves it in this order:
identity.emailmatching a contactidentity.user_idseen beforeidentity.emailseen before without a contactidentity.anonymous_idseen before
A person nobody has seen yet is created, without a contact. No contact is ever created from an event. When a contact with that email appears later, the person joins it and their earlier events move with them. Identifiers the event carries that are new are remembered for that person. A new user_id never joins a person that already has a different one; that is two accounts on one browser, and it becomes a new person plus an identity conflict.
When identifiers point at two different people, the event goes to the first match in the order above and an identity conflict is recorded for review. Nothing merges automatically on an event.
{
"name": "account.created",
"identity": { "user_id": "usr_812", "email": "jane@acme.com", "anonymous_id": "anon_5f2", "account": { "id": "acct_77", "domain": "acme.com" } },
"data": { "plan": "pro" }
}{
"name": "trial.started",
"email": "jane@acme.com",
"data": { "plan": "pro" }
}Response: 202 Accepted
{
"event_id": "8b0a...",
"contact_id": "c1d2...",
"person_id": "5e7f...",
"name": "trial.started",
"received_at": "2026-09-01T10:00:00Z",
"resolution": "contact",
"processing_status": "pending",
"identity_conflicts": [],
"matched_waits": 1,
"matched_runs": [
{ "run_id": "run_123", "campaign_id": "camp_1", "step_id": "wait_456", "contact_id": "c1d2...", "state": "waiting", "mode": "live" }
]
}matched_runs names every step run the event resumed; an empty list means nothing was waiting. resolution is contact when the event reached a contact and person when it only reached a person. processing_status is pending until goals have been evaluated, which normally happens before the response; a failure there never fails the request and is retried in the background.
A replay with the same Idempotency-Key and the same body returns the stored event and resumes nothing new. The same key with a different body answers 409 idempotency_key_conflict, however long ago the first request was. A legacy email or contact_id that matches no contact answers 404 event_contact_not_found with a fix.
Test a product event
POST /events/test
Auth: Scope WRITE_EVENTS · Org permission send_campaigns
Takes the same body as POST /events and stores nothing. The response says whether the event is valid, how it would resolve, and which goals it would match with what value:
{
"valid": true,
"name": "invoice.paid",
"resolution": { "method": "alias_user_id", "person_id": "5e7f...", "contact_id": null, "conflicts_with": [], "would_be_stored": true, "creates_contact": false },
"goals": [{ "goal_id": "g_1", "name": "Revenue", "kind": "revenue", "revision": 2, "matched": true, "value": { "currency": "USD", "amount_minor": 4900 } }],
"warnings": ["No contact matches; the event would be stored against a person without a contact and appear as an unmatched identity until linked."]
}method is one of contact, contact_email, alias_user_id, alias_email, alias_anonymous_id, new_person, or no_contact (a legacy-shaped event that would be refused).
Event schema
GET /events/schema
Auth: Scope READ_ANALYTICS · Org permission view_analytics
Event names seen in the last days (default 90, up to 366), with occurrences, unique people, first and last seen, the goals listening to each, and the top-level data properties with their JSON types, sampled from the 200 most recent events of each name.
Link identities
POST /identities/link
Auth: Scope WRITE_EVENTS · Org permission send_campaigns
Tells SendSets that identifiers belong to one person, typically right after signup or login. Unlike an event, a link is an authenticated statement, so it may merge people and resolve their retained history.
| Field | Type | Description |
|---|---|---|
contact_id | string (UUID) | Optional contact to link. |
identity | object | user_id, email, anonymous_id, account, as on events. |
Send at least two identifiers, or one identifier and an account. A person known only by weaker identifiers (an anonymous id, an email without a contact) is merged in, with their events and conversions. Two people with different contacts, or with different user ids, are never merged; the link records an identity conflict instead.
{ "person_id": "5e7f...", "contact_id": "c1d2...", "merged_people": 1, "events_resolved": 14, "conflicts": [] }A link naming the same identifiers again changes nothing, so retries are safe without an Idempotency-Key. The public tracking snippet never accepts a bare email; server-side links and events are how an email joins a browser's history.
List product events
GET /events
Auth: Scope READ_CAMPAIGNS · Org permission view_campaigns
| Parameter | In | Type | Description |
|---|---|---|---|
name | query | string | Only this event name. |
email | query | string | Only this contact, by email. |
contact_id | query | string (UUID) | Only this contact, by id. |
person_id | query | string (UUID) | Only this person. Implies include_unresolved=true. |
since | query | string (RFC 3339) | Only events received after this time. |
include_unresolved | query | boolean | Also return identity-mode events that matched no contact. Default false, which keeps the original contact-only listing. |
limit | query | integer | Page size, 1 to 200. Default 50. |
cursor | query | string | Opaque cursor from pagination.next_cursor. |
{
"data": [
{ "id": "8b0a...", "organization_id": "...", "contact_id": "c1d2...", "person_id": "5e7f...", "name": "trial.started", "data": { "plan": "pro" }, "occurred_at": "...", "received_at": "...", "resolution": "contact", "processing_status": "processed" }
],
"pagination": { "next_cursor": "", "has_more": false }
}Get a product event
GET /events/:id
Auth: Scope READ_CAMPAIGNS · Org permission view_campaigns
Returns one event, or 404 event_not_found.
From the CLI
sendsets events send trial.started --email jane@acme.com --data '{"plan":"pro"}'
sendsets events list --name trial.started --since 2026-09-01T00:00:00Z
sendsets events tail --intent CAMPAIGNevents send mints an Idempotency-Key per call; pass --idempotency-key to make a retry a replay.
