# 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](/api-reference/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}`](/api-reference/endpoints/contacts/upsert-contact-by-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`](/api-reference/endpoints/activities/ingest-activities). When something *happens* (they upgraded, they connected an account, they went live), record it as an `activity` so a workflow can enroll on it and your Segments can count it.


  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](https://help.sendops.dev/audience/attributes#derived-attributes) like `exists(send)` or `count(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/account` is caller-scoped.** [`GET /v1/account`](/api-reference/endpoints/account/get-account) returns *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.





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.


  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](/api-reference/activities#identity-email-external_id-and-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:

<CodeBlock lang="javascript" code={`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:



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.


  - [Contacts & Topics](/api-reference/contacts) — the full `by-external-id` upsert semantics, attribute validation, and inline List membership.
  - [Activities](/api-reference/activities) — the activity object, identity resolution, idempotency, and batching up to 1000 per request.
  - [Keeping contact state current](https://help.sendops.dev/audience/syncing-external-state) — the same pattern from the product side, with derived attributes and consent context.