Activities

Search Documentation

Search across all developer documentation

Activities

An activity is a free-form behavioral signal your integration records about one contact: { name, properties, occurred_at }. Push purchase, page_view, trial_started — whatever your product knows about a person — and SendOps stores it append-only against the contact, queryable per contact and org-wide.

There is no schema and no registry: an activity name is just a value, and properties is arbitrary JSON. You never pre-declare anything.

Not a message event, not an analytics event

“Event” means several things in SendOps. SES message events (delivery, bounce, open, click) live under Messages. Activities are the opposite direction: signals you push about a contact. They power future audience segmentation (“did X within 30 days”), not deliverability reporting.

Ingest

POST /v1/activities accepts a single object or an array of up to 1000. It always answers 202 Accepted with one result per item, in input order — a batch is best-effort, never all-or-nothing.

curl -X POST https://api.sendops.dev/v1/activities \
-H "Authorization: Bearer $SENDOPS_API_KEY" \
-H "Content-Type: application/json" \
-d '[
  {
    "name": "purchase",
    "email": "ada@example.com",
    "properties": { "amount": 129.5, "sku": "PRO-ANNUAL" },
    "occurred_at": "2026-07-02T09:30:00Z",
    "idempotency_key": "order-8841"
  },
  { "name": "page_view", "external_id": "cust_42" }
]'
{
"data": [
  { "index": 0, "status": "accepted",
    "activity_id": "0197...", "contact_id": "0195..." },
  { "index": 1, "status": "rejected", "reason": "unknown_external_id" }
]
}

Accepted items are written asynchronously — expect a short delay (seconds) before they appear on the read endpoints.

Identity: email, external_id, and stub contacts

Each item identifies its contact by email or external_id (or both). Resolution order is external_id first, then email:

  • Known identity — the activity attaches to that contact.
  • Unknown email — a stub contact is created. A stub is a normal contact row with no consent state and no attributes: it can be read, enriched, and (later) segmented, but it is not mailable until a real contact write or subscription gives it consent. Membership ≠ mailability.
  • Unknown external_id, no email — the item is rejected (unknown_external_id). SendOps can’t create a contact without an email.
  • Both supplied — the contact is resolved (or created) by email and the external_id is attached to it, becoming a stable join key for future items. If the contact already carries a different external_id, the item rejects with external_id_conflict.

external_id is your system’s identifier (unique per org). Once attached, you can stop sending emails entirely and identify contacts by it alone. It is also returned on the contact object.

Idempotency

Retries are the client’s job on a 5xx/timeout — and safe: give each item an idempotency_key (e.g. your order id). A replayed key within 24 hours (org-scoped) returns status: accepted with duplicate: true, usually with the original activity_id so you can reconcile. Items without a key are never deduplicated.

The request-level Idempotency-Key header used by other write endpoints is deliberately not used here — dedup is per item, inside the body.

idempotency_key is best-effort, not exactly-once

The 24h window is a retry-collapse de-noiser: it can expire, and on a rare internal error the check can fail open rather than block ingest. It is not an exactly-once or once-per-lifetime guarantee. For a milestone that must only ever happen once for a contact — signed_up, first_send, trial_started — use dedup_mode: "once" instead (below).

Once-per-lifetime milestones: dedup_mode

{
"name": "signed_up",
"email": "ada@example.com",
"dedup_mode": "once"
}

Set dedup_mode on an item to choose the dedup mechanism:

ValueBehavior
retry (default)The 24h idempotency_key retry-collapse window described above.
onceDurably claims the item at most once per (contact, name), forever — no TTL. idempotency_key is ignored in this mode.

dedup_mode: "once" fails closed: if the underlying claim store is briefly unavailable, the item is rejected with reason: "dedup_unavailable" (safe to retry) rather than risk admitting an unclaimed duplicate. This is the opposite of the default mode, which fails open (admits rather than blocks) on an internal error — deliberately, since a duplicate page_view is harmless but a blocked high-volume ingest pipeline is not. A once-per-lifetime milestone has the opposite risk profile, hence the opposite failure mode.

This is ingestion dedup, not enrollment dedup

dedup_mode: "once" stops a duplicate activity from being written. It does not, by itself, stop a duplicate Drip Workflow send — that boundary is the workflow’s reentry policy (reentry once is the default and durable) on enter on activity.<name> triggers. See Workflows. The two are complementary: dedup_mode: once reduces how often a duplicate activity happens; reentry once guarantees that even if one slips through, it produces at most a duplicate timeline row, never a duplicate send.

If you run a periodic sync that reconciles external state into SendOps by re-emitting the same milestone on every pass, dedup_mode: "once" is what makes that safe to run unconditionally — no need to hand-tune a re-emit window against the retry-mode TTL.

Rejection reasons

Per-item reason values (this is a per-item vocabulary inside the 202, not an error code):

ReasonMeaning
invalid_nameMissing, blank, or over 200 chars.
invalid_email / invalid_external_id / invalid_idempotency_keyMalformed or over length limits (256 / 255 chars).
invalid_dedup_modededup_mode was set to something other than retry or once.
missing_identityNeither email nor external_id supplied.
unknown_external_idexternal_id matched nothing and no email was given to create a stub.
external_id_conflictThe key already belongs to a different contact (or conflicting keys within one batch).
properties_too_largeSerialized properties exceeds 32 KiB.
rate_limitedThe per-org item-volume cap tripped — retry after the window resets.
dedup_unavailablededup_mode: "once" and the durable claim store was briefly unavailable — safe to retry.

Whole-request errors are reserved for malformed JSON, an empty payload, a batch over 1000 items, or a body over 1 MiB (422 validation_failed), and the per-key request rate limit (429).

Reading activities back

Three read endpoints, all under api.activities.view, all following the standard pagination and plan-retention rules:

EndpointReturns
GET /v1/activitiesOrg-wide list, newest first. Filters: name, from/to, contact (id, email, or external_id — an unknown reference returns an empty page).
GET /v1/contacts/{id}/activitiesOne contact’s timeline. Filters: name, from/to.
GET /v1/contacts/{id}/activities/summaryOne row per activity name: count_total, count_30d, first_at, last_at. Precomputed — cheap to poll.

properties is echoed back exactly as submitted. It is descriptive payload only: SendOps never evaluates or type-checks it server-side.

Scopes

ScopeGrantsPII
api.activities.writePOST /v1/activitiesWrites contact identities; creates stub contacts.
api.activities.viewThe three read endpointsYes — contact emails and your free-form properties. Grant deliberately.

api.activities.write is a .write (not .manage) scope: the surface is append-only — nothing can be edited or deleted through it.

Retention & limits

  • Activities are retained for 365 days from ingestion (the read window is additionally capped by your plan’s retention).
  • Max 1000 items per request, 1 MiB body, 32 KiB properties per item.
  • Per-org item volume: 2000 items/minute by default (per-item rate_limited rejects when exceeded), plus the standard per-key request rate limit.