SendsetsDocs
Endpoint reference

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_event step 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

FieldTypeRequiredDescription
namestringyesDotted lowercase words: [a-z0-9_-]+(\.[a-z0-9_-]+)*, up to 120 characters.
emailstringone ofThe contact's email. The contact must exist.
contact_idstring (UUID)one ofThe contact's id. The contact must exist.
identityobjectone ofYour app's identifiers for the person. Exactly one of email, contact_id, or identity.
identity.user_idstringYour app's stable user id. Printable, no spaces, up to 200 characters.
identity.emailstringThe person's email. Matches a contact when one has that address.
identity.anonymous_idstringA pre-signup id, such as the one your frontend stores.
identity.accountobject{ "id", "name", "domain" } for the customer account the person belongs to. Groups people; never merges them.
occurred_atstring (RFC 3339)noWhen it happened. Default: now. At most a day in the future. A wait only matches events that occurred after the contact entered it.
dataobjectnoEvent 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:

  1. identity.email matching a contact
  2. identity.user_id seen before
  3. identity.email seen before without a contact
  4. identity.anonymous_id seen 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.

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.

FieldTypeDescription
contact_idstring (UUID)Optional contact to link.
identityobjectuser_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

ParameterInTypeDescription
namequerystringOnly this event name.
emailquerystringOnly this contact, by email.
contact_idquerystring (UUID)Only this contact, by id.
person_idquerystring (UUID)Only this person. Implies include_unresolved=true.
sincequerystring (RFC 3339)Only events received after this time.
include_unresolvedquerybooleanAlso return identity-mode events that matched no contact. Default false, which keeps the original contact-only listing.
limitqueryintegerPage size, 1 to 200. Default 50.
cursorquerystringOpaque 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 CAMPAIGN

events send mints an Idempotency-Key per call; pass --idempotency-key to make a retry a replay.

On this page