Connect your app
The simplest way to feed CampaignOS from your own product is one call per thing that happens to a user: a sign-up, a login, an opened checkout, a completed payment. The ingest endpoint takes that one call and does everything a contact needs: it creates or updates the person, records their consent, puts them in segments, starts the automations those segments trigger, and logs the event.
POST /api/workspaces/{workspaceId}/contacts/ingest
Auth: session, or an API key with contacts.write (and segments.write when segments is sent).
curl -X POST https://your-domain.com/api/workspaces/WORKSPACE_ID/contacts/ingest \
-H "Authorization: Bearer cos_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"type": "PAYMENT_OPEN",
"email": "jane@example.com",
"firstName": "Jane",
"externalId": "user_8f2c",
"locale": "pt-BR",
"country": "BR",
"city": "São Paulo",
"region": "SP",
"latitude": -23.55,
"longitude": -46.63,
"timezone": "America/Sao_Paulo",
"emailConsent": { "status": "OPTED_IN", "source": "signup_email", "updatedAt": "2026-10-07T10:12:00.000Z" },
"segments": ["PAYMENT_OPEN"],
"attributes": {
"last_access_at": "2026-10-05T13:30:00.000Z",
"total_paid": 0,
"completed_payments": 0,
"payment_url": "https://checkout.stripe.com/c/pay/cs_…"
}
}'| Field | Type | Description |
|---|---|---|
type | string | The event to record, for example SIGN_UP or PAYMENT_OPEN. Optional, but without it nothing is logged and no event-triggered automation starts. |
email | string | Finds the contact; creates one when none exists. |
contactId | string | Finds the contact by its CampaignOS id instead of the email. |
externalId | string | Your own user id. Kept unique per workspace: an id already used by another contact is ignored. |
firstName, lastName, phone | string | Profile fields. Only the fields you send change. |
locale, language, country | string | The person’s language and country, in any mix; see Language and country. |
region, city, latitude, longitude, timezone | string / number | Where the person is. Coordinates are only stored as a pair. Anything unusable is dropped rather than rejecting the call. |
attributes | object | Merged into the contact’s attributes: keys you send are set, keys you leave out are kept. |
metadata | object | Stored on the event only. |
emailOptIn | boolean | Records an email opt-in for a contact that has no email consent yet. Never overrides an opt-out. |
emailOptOut | boolean | Opts the contact out of email, as an unsubscribe link would, and fires the unsubscribe.email webhook. Wins over the other consent fields. |
emailConsent | object | The person’s own answer about marketing email, as your app holds it: status (OPTED_IN, OPTED_OUT or null), source, updatedAt. Applied unless the consent changed in CampaignOS after updatedAt, so an unsubscribe link wins over an older answer; without updatedAt it only fills a missing consent. A null status changes nothing, and an opt-out recorded this way does not fire unsubscribe.email. Wins over emailOptIn. |
segments | string[] | Static segments the contact joins, by name or id. Joining starts the automations that trigger on entering those segments. Unknown names are reported back, not created. |
Response (200):
{
"contactId": "ct_abc123",
"created": false,
"segmentsJoined": ["PAYMENT_OPEN"],
"unknownSegments": [],
"eventId": "evt_9k2"
}What one call does, in order
- Contact — found by
contactId, else byemail; created when neither matches and an email was sent. Two calls for a brand-new address arriving at the same time end up on one contact. - Consent —
emailOptOutrecords an opt-out; otherwiseemailConsentapplies your app’s answer; otherwiseemailOptInrecords an opt-in if the contact has none. Automations only email contacts whose email consent is opted in; transactional campaigns ignore consent. - Segments — the contact joins the listed static segments. A contact that is already in a segment does not “enter” it again, so automations triggered by entering the segment do not restart for them.
- Automations — entering a segment starts the automations that trigger on it (ten or fewer contacts run immediately, larger batches are queued).
- Event — the
typeis logged with itsmetadata. Automations triggered by that event start, dynamic segments are re-evaluated for the contact, and scoring rules run.
Steps 4 and 5 run after the response is sent, so the call returns in well under a second.
Choosing between a segment and an event
- Use
segmentsfor states a person reaches once: signed up, finished the tutorial, paid. Each static segment keeps its members, so you can see and count who reached each state, and automations can skip people who already received a stage. - Use the event
typefor things that can happen again: a login, an opened checkout, a reset request. An automation with an Event trigger starts every time the event arrives (subject to its re-entry settings), which a segment cannot do.
Sending both is normal: "type": "PAYMENT_OPEN" with "segments": ["PAYMENT_OPEN"] records the state and lets an abandoned-checkout automation start on each attempt.
Attributes that automations rely on
Automations and segment rules can read any attribute (attributes.<name> in rules, {{contact.<name>}} in templates). A few conventions are worth keeping:
| Attribute | Used for |
|---|---|
last_access_at | Inactivity rules such as daysSince.attributes.last_access_at, the basis of win-back automations. Send it with a daily “user was active” event. |
completed_payments, total_paid | ”Has paid” checks. Prefer completed counts over attempted ones. |
payment_url | The checkout a person left, so a reminder email can send them straight back to it. |
Calling it from n8n, Make or Zapier
Point an HTTP request node at the endpoint with a header credential Authorization: Bearer <api key> and build the body from the incoming event. Keep the contract small: the endpoint tolerates missing fields, so map what you have and leave the rest out rather than sending empty strings.
Related endpoints
POST /api/webhooks/inbound/{endpointId}— the signed inbound webhook, when you prefer per-endpoint secrets over API keys.POST /api/workspaces/{workspaceId}/contacts/import— bulk import for existing lists.POST /api/script/event— events from the browser through the website script.