Syncing externally-observed state
Much of what you want to segment or trigger on isn’t generated inside SendOps. A contact’s plan lives in your billing system; whether they’ve finished onboarding lives in your product database; the state of a downstream account lives in that account. SendOps can only see the mail it sends and the activities you push to it — so anything that happens in another system, or in a different org, has to be carried in.
The pattern for that is a scheduled reconciliation sweep: on a timer (or off a change-data-capture stream / webhook), read your source of truth and push the current picture into SendOps. Two primitives do the carrying:
- Current state → attributes, via
PUT /v1/contacts/by-external-id/{external_id}. Upsert the contact’s attributes (plan, lifecycle stage, health score) to match your source. This is declarative — you send the desired state, not a delta. - Milestones → activities, via
POST /v1/activities. When something happens (they upgraded, they connected an account, they went live), record it as anactivityso a workflow can enroll on it and your Segments can count it.
Attributes are state; activities are events
Use an attribute for a value that has a current answer (“what plan are they on right now”) — the upsert overwrites it. Use an activity for something that occurred at a point in time (“they upgraded at 14:03”) — activities are append-only and keep their history. Most reconciliation jobs write both: the attribute so rules read the latest, and a milestone activity so a workflow can fire the moment it changes.
Why you can’t just observe it
Two things a reader reasonably reaches for don’t cross a system or org boundary — which is exactly why the sweep exists:
- Derived attributes are intra-org. A derived attribute like
exists(send)orcount(activity.order)aggregates over this contact’s own event and activity stream in this org. It answers “has this person ever been sent mail by us,” never “has this person ever sent mail from their own SES account.” If the behaviour happened in another system or another org, there is no stream here to aggregate — you have to write the fact in yourself. /v1/accountis caller-scoped.GET /v1/accountreturns your org’s onboarding, AWS, and plan state — there is no way to read another org’s. If you’re orchestrating over your customers’ SendOps orgs (each signup is their own org), you observe each one with its own API key and reconcile the result onto a contact in your marketing org.
The boundary is the whole point: SendOps won’t invent state it can’t see. Reconciliation is how you make external truth visible to Segments and Drip Workflows.
The sweep
A reconciliation job is a loop: for each record in your source of truth, upsert the contact by its stable external_id and, when a milestone flipped since last run, emit an activity.
# 1) Current state — declarative upsert keyed by YOUR id.
# Re-running with the same external_id always converges on the same contact,
# forever (a permanent unique constraint, not a 24h idempotency window).
curl -X PUT 'https://api.sendops.dev/v1/contacts/by-external-id/cust_9182' \
-H "Authorization: Bearer $SENDOPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"attributes": {
"plan": "pro",
"lifecycle_stage": "activated",
"aws_production_access": true
}
}' # 2) Milestone — append-only, so a workflow can enroll the moment it happens.
# idempotency_key makes the nightly re-run safe: a replay writes nothing new.
curl -X POST 'https://api.sendops.dev/v1/activities' \
-H "Authorization: Bearer $SENDOPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "reached_production",
"external_id": "cust_9182",
"occurred_at": "2026-07-09T14:03:00Z",
"idempotency_key": "cust_9182-reached_production"
}' Both calls are safe to re-run. The upsert converges on the same contact every time (external_id is a permanent unique key on (org, external_id), not the 24-hour Idempotency-Key header used elsewhere). The activity’s per-item idempotency_key dedups a replayed milestone for 24 hours — give each logical milestone a stable key (here, external_id + activity name) so a nightly job that re-scans the same rows doesn’t double-record.
Push before you rely on it in a workflow
Streaming an activity for someone SendOps has never seen creates a stub contact — it counts in Segment rules but isn’t mailable until a real contact write gives it an email and consent. If your milestone activity is meant to trigger a send, make sure the reconciliation upsert (which carries the email and consent) runs too. See Activities → identity & stub contacts.
Worked example: mirror a customer’s go-live
Suppose each of your signups runs their own SendOps org, and your marketing org wants to send a “you’re in production” congratulations when they leave the SES sandbox. Your marketing org can’t read their /v1/account — so a nightly job does, per customer, with their key, and reconciles the result onto the matching contact in your org:
for (const customer of await listCustomers()) {
// Observe THEIR state with THEIR key (caller-scoped).
const acct = await fetch("https://api.sendops.dev/v1/account", {
headers: { Authorization: `Bearer ${customer.sendopsKey}` },
}).then((r) => r.json());
const live = acct.aws?.sandbox === false;
// Reconcile onto a contact in OUR marketing org, keyed by our id for them.
await fetch(
`https://api.sendops.dev/v1/contacts/by-external-id/${customer.id}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.MARKETING_ORG_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email: customer.email,
attributes: { aws_production_access: live },
}),
},
);
// First time we see them live, record the milestone a workflow enrolls on.
if (live && !customer.reachedProductionRecorded) {
await fetch("https://api.sendops.dev/v1/activities", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MARKETING_ORG_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "reached_production",
external_id: customer.id,
idempotency_key: `${customer.id}-reached_production`,
}),
});
}
} A Drip Workflow in the marketing org then enrolls on the milestone and sends in the transactional lane:
workflow "Go-live congrats" v1 {
enter on activity.reached_production
send "youre-in-production" transactional
} The attribute (aws_production_access) keeps Segments current for as long as the state holds; the activity (reached_production) is the one-time edge a workflow fires on. That split — attribute for the standing fact, activity for the moment it changed — is the core of every reconciliation sweep.
Where to go next
- Contacts & Topics — the full
by-external-idupsert semantics, attribute validation, and inline List membership. - Activities — the activity object, identity resolution, idempotency, and batching up to 1000 per request.
- Keeping contact state current — the same pattern from the product side, with derived attributes and consent context.