Sumidatasumidata.io
Sign in
Docs/Guides/Server-side ingest
Guides · Conversions · Server-side ingest

Server-side ingest

Send conversions from your backend when the source of truth isn't the browser — webhooks, subscriptions, invoicing, offline sales. All attribution, experiments, and dedup still work.

01Server-side ingest API

One endpoint, one JSON body. Authentication via project ID in the payload.

URLhttps://api.sumidata.io/sdk/ingest
MethodPOST
Content-Typeapplication/json
AuthprojectId in JSON body (no Bearer token)
Idempotency(orderId, productId) per conversion
POST

https://api.sumidata.io/sdk/ingestPOST /sdk/ingest

Accepts one envelope per request. The envelope carries identity (deviceId, optional sessionId, optional externalUserId) and one or more ofevents, logs, replayEvents. Typical server-side use only sets events.

Request body

NameTypeRequiredDescription
projectIdUUIDyesYour Sumidata project ID
deviceIdUUIDyesSDK-issued device identifier. If the user never visited the browser, use the zero-UUID 00000000-0000-0000-0000-000000000000
sourcestringOrigin tag. Recommended values: realtime · import · backend. Any string is accepted, but reports rely on these three — stick to them
sessionIdUUIDBrowser session ID captured from the SDK. Required when source is omitted (treated as sdk); optional for backend sources. Setting it enables UTM inheritance and experiment join
replayIdUUIDRequired only for browser SDK ingest (replay chunks). Omit for backend sources
externalUserIdstringStable user identifier. Optional, but needed for conversions without a browser session (email, UUID, account ID)
eventsEvent[]Array of event objects. See full payload reference below
logsLog[]Optional array of log entries for backend-emitted diagnostics. See /docs/errors for schema
i
No hard batch limit is enforced on events. As a practical matter, keep batches under 100 events to stay well below the Beacon/HTTP body ceiling and to keep retries cheap. Replay chunks have a hard 50 MB cap per replay — separate from this endpoint's per-request limits.

Example request

curl
curl "https://api.sumidata.io/sdk/ingest" \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"projectId":"proj_…","deviceId":"00000000-0000-0000-0000-000000000000","externalUserId":"user_7e2a9c","source":"backend","events":[{"name":"purchase","orderId":"ord_01HW","totalAmount":99.99,"currency":"USD"}]}'
ingest.ts
import axios from 'axios'

await axios.post('https://api.sumidata.io/sdk/ingest', {
  projectId: process.env.SUMIDATA_PROJECT_ID,
  deviceId: user.sumidataDeviceId ?? '00000000-0000-0000-0000-000000000000',
  sessionId: user.sumidataSessionId,
  externalUserId: user.id,
  source: 'backend',
  events: [{
    name: 'purchase',
    orderId: order.id,
    totalAmount: order.total,
    currency: 'USD',
    timestamp: Date.now(),
  }]
})

Success · 200 OK

response.json
{ "status": "ok" }

A successful response is just { status: "ok" }. Events are written synchronously, so a 200 means every event in the batch landed in ClickHouse. The other possible success payload is { status: "replay_full", reason: "size_limit" }, which only applies to replay chunks that exceeded the 50 MB per-replay cap — logs and events in the same request are still stored.

Errors

Validation errors return 400 with a field-indexed object: { "errors": { "<path>": ["<message>"] } }. The full set:

400deviceId requiredMissing deviceId in the envelope
400deviceId invalid UUIDdeviceId is not a valid UUID. Zero-UUID is allowed
400sessionId/replayId required (sdk)When source is omitted or equals sdk, both IDs must be present and valid
400events[i]._category invalidNot one of awareness, acquisition, activation, revenue, retention, referral
400events[i]._surface invalidNot one of marketing_site, app, mobile_app, docs
400events[i]._feature invalidEmpty string or contains characters outside [a-z0-9_]
400Duplicate conversion: <key>An event with the same (orderId, productId) has already been ingested. The entire batch is rejected — retry with non-duplicate events only
!
The dedup check aborts the whole batch on the first duplicate. For batched imports, send one event per envelope (or pre-filter on your side) so a single duplicate doesn't lose the rest. See Historical backfill.

02The source field

Tell Sumidata where the event came from so reports can filter by origin.

Every envelope carries a source. Any string is accepted, but reports and the Partner API filter by the three canonical values below — stick to them unless you have a dedicated reason to branch.

ValueWhen to use
realtimeLive event from a webhook or domain listener. The user's transaction just happened.
importBackfill of historical orders — one-shot or periodic reconciliation from your database.
backendAny other server-originated event that doesn't fit the two above (scripts, admin tools).
sdkReserved — the browser SDK uses this automatically. Do not set from a backend, or validation will require a sessionId and replayId.
i
The server also uses source to relax validation: when source !== 'sdk', sessionId and replayId become optional. This is the mechanism that lets backend / import events fire without a browser session.

03External user IDs

externalUserId is the cross-device anchor — pick a stable ID and never change it.

Server-side conversions don't have cookies or a device ID. Attribution hinges on externalUserId — the same identifier you pass to the browser SDK's identify() call. Pick one that won't change: your internal user UUID, the account ID, or a hashed email.

browser.js
// Browser — at login
Sumidata.push('identify', [user.id])
server.ts
// Server — at conversion. Same ID.
await ingest({ externalUserId: user.id, … })
  • Normalize before sending — trim, lowercase email, checksum-case wallet addresses.
  • Don't send a volatile ID (session cookies, generated tokens) — it breaks the user's journey.
  • If you change the ID later, old events won't stitch to the new identity.

04Linking browser session to server

Carry the browser session into your server so conversions inherit UTM attribution.

Server-side conversions lose UTM attribution by default — there's no URL in your webhook handler. Fix this in three steps:

1

Read the browser session + device IDs

After login, grab both IDs from the SDK. sessionId carries UTM inheritance for the current journey; deviceId is needed if you later want to register experiments from the server.

link-session.js
// Client — runs once the user is authenticated
const sumidataSessionId = window.Sumidata?.getSessionId?.()
const sumidataDeviceId = localStorage.getItem('sumidata_device_id')

await fetch('/api/users/me/analytics-ids', {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ sumidataSessionId, sumidataDeviceId })
})
2

Store them on the user row

Persist both IDs next to the user so every later server-side call can reach them.

users.controller.ts
// Server — save the IDs for later
user.sumidataSessionId = req.body.sumidataSessionId
user.sumidataDeviceId  = req.body.sumidataDeviceId
await users.save(user)
3

Attach IDs to every server-side conversion

Pass both IDs with the event — UTMs, referrer, landing page, and experiment variants are joined automatically.

ingest.ts
await ingest({
  externalUserId: user.id,
  deviceId: user.sumidataDeviceId,
  sessionId: user.sumidataSessionId,
  source: 'realtime',
  events: [{ name: 'purchase', … }]
})
i
If the user has no browser session (API-only signup, webhook without a prior visit), use the zero-UUID 00000000-0000-0000-0000-000000000000 for deviceId and omit sessionId. Attribution falls back to externalUserId alone — see section 07.
i
With sessionId present, Sumidata joins the conversion to the browser session that captured UTMs, referrer, and landing page — even if the payment confirmation arrives minutes later via webhook.

05Full purchase payload reference

The full event schema. Only name, orderId, and totalAmount are strictly required for a purchase.

Identifiers

NameTypeRequiredDescription
namestringyesEvent name — purchase for revenue events
orderIdstringyesUnique order identifier. Part of the dedup key
productIdstringSKU, plan ID, or asset identifier. The second half of the dedup key — always send for multi-item orders
productNamestringHuman-readable product name for reports
productCategorystringYour category taxonomy (e.g. subscription, addon, one-off)

Pricing

NameTypeRequiredDescription
totalAmountnumberyesNet revenue in major units (e.g. 79.99). Send the amount after discount
currencystringISO 4217 code. Defaults to USD
quantitynumberUnits purchased
unitPricenumberPrice per unit (before discount, in major units)
discountAmountnumberDiscount applied, in major units. Keep as a separate field — Sumidata reports show gross / discount / net breakdowns when this is present
couponCodestringPromo code redeemed on this line item

Lifecycle

NameTypeRequiredDescription
isPrimarySalebooleanTrue if this is the user's first-ever purchase. See section 06
timestampnumberUnix ms. When the purchase actually happened (not ingest time)

Attribution (optional — auto-resolved for conversions)

NameTypeRequiredDescription
utmSourcestringOverride. If omitted, Sumidata fills this from stored user/device attribution. See section 07
utmMediumstring
utmCampaignstring
utmTermstring
utmContentstring

Event metadata (AAARRR funnel tagging)

NameTypeRequiredDescription
_categoryenumawareness · acquisition · activation · revenue · retention · referral. Invalid values return 400
_surfaceenummarketing_site · app · mobile_app · docs
_featurestringLowercase alphanumeric and underscores only ([a-z0-9_]+)

Free-form

NameTypeRequiredDescription
payloadobjectAny fields not listed above. Stored as JSON, available in queries via JSONExtract* functions
full-event.json
{
  "name": "purchase",
  "orderId": "ord_01HW…",
  "productId": "plan_pro_monthly",
  "productName": "Pro (monthly)",
  "productCategory": "subscription",
  "quantity": 1,
  "unitPrice": 99.00,
  "totalAmount": 79.00,
  "currency": "USD",
  "discountAmount": 20.00,
  "couponCode": "LAUNCH20",
  "isPrimarySale": true,
  "timestamp": 1713609164000,
  "_category": "revenue",
  "_feature": "checkout",
  "_surface": "app",
  "payload": { "billingProvider": "stripe" }
}

06Primary vs secondary sales

Flag first-ever purchases separately from repeat revenue — essential for LTV and cohort analysis.

isPrimarySale marks whether this is the customer's first-ever purchase. It drives new-vs-returning revenue splits, first-purchase LTV cohorts, and acquisition CAC math. Compute it against your database before firing.

primary-sale.sql
-- Returns true if the user has no earlier purchases
SELECT NOT EXISTS (
  SELECT 1 FROM orders
  WHERE user_id = $1 AND status = 'paid' AND id <> $2
) AS is_primary_sale
conversion.ts
const isPrimarySale = await orders.hasNoPriorPaidOrders(user.id, order.id)

await ingest({
  externalUserId: user.id,
  source: 'realtime',
  events: [{
    name: 'purchase',
    orderId: order.id,
    totalAmount: order.total,
    isPrimarySale,
  }]
})
i
Be careful during backfill — when replaying historical orders chronologically, track which users have already had a "primary" flag fired in the current run so the second order doesn't also get flagged. See Historical backfill.

07UTM attribution — auto-resolution

Conversions without explicit UTM fields inherit them from stored attribution history — no manual wiring needed.

For any event that looks like a conversion (has an orderId), Sumidata auto-fills missing UTM fields from its attribution tables. Resolution order:

  1. Event payload — if utmSource etc. are set on the event, they win.
  2. User attribution — latest entry in user_attribution for this externalUserId. This is populated from every browser session the user has ever had on any device.
  3. Device attribution — latest entry in device_attribution for this deviceId. Used when there's no linked user yet.
i
The practical consequence: if the user's web session ever saw UTMs and they later came back from a webhook / offline / subscription renewal, their conversion still attributes correctly — you don't need to copy UTMs around manually. Just call identify() on the SDK once, and the server-side conversion inherits the whole journey.

Manual override — for edge cases

If the user never had a web session (API-only signup, mobile-only flow), stash the acquisition context at registration and attach it manually at conversion time:

register.ts
// At signup — persist the acquisition context
const urlParams = Object.fromEntries(new URL(req.headers.referer).searchParams)
await users.create({ …userData, urlParams })
with-utm.ts
// At conversion — copy the stored UTMs onto the event
const event: SumidataEvent = {
  name: 'purchase', orderId: order.id, totalAmount: order.total,
  utmSource: user.urlParams?.utm_source,
  utmMedium: user.urlParams?.utm_medium,
  utmCampaign: user.urlParams?.utm_campaign,
}

await ingest({ externalUserId: user.id, source: 'realtime', events: [event] })

Multi-touch history

The user_attribution table stores every touchpoint, not just the latest. When you call the identify endpoint (/sdk/link-session-to-customer), Sumidata copies every prior device_attribution row onto the user — so you get full first-touch / last-touch / N-touch attribution out of the box. Query the table directly for custom journey analysis.

08A/B experiments

Register variant assignments once per session — sticky, idempotent, joined to every conversion automatically.

Experiments are a first-class Sumidata entity, not a tag on an event. Assignments live in a dedicated table (session_experiments) and get joined into every downstream analysis — revenue by variant, funnel-step by variant, replay filtering by variant. For the full integration guide (GrowthBook / Statsig / LaunchDarkly / Optimizely recipes, query patterns, limitations), see Experiments.

POST

https://api.sumidata.io/sdk/session-experiments

Register one or more variant assignments for a session. Fire this once after your experiment framework resolves assignments — ideally before the first user interaction in the session.

Request body

NameTypeRequiredDescription
projectIdstringyesYour Sumidata project ID
deviceIdUUIDyesThe SDK-issued device identifier
sessionIdUUIDyesThe session to bind the assignments to
experiments{id, variant}[]yesNon-empty array. Each entry needs a non-empty id and variant

Example request

curl
curl "https://api.sumidata.io/sdk/session-experiments" \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"projectId":"…","deviceId":"…","sessionId":"…","experiments":[{"id":"pricing-v3","variant":"control"}]}'

Success · 200 OK

response.json
{ "status": "ok" }     // new assignments inserted
{ "status": "skip" }   // all (session, experimentId) pairs already registered — no-op

Browser — via the SDK

The SDK has a first-class helper that takes the same shape and handles device/session IDs automatically:

browser-experiments.js
Sumidata.push('identifyExperiment', [[
  { id: 'pricing-v3', variant: 'control' },
  { id: 'onboarding-flow', variant: 'short' },
]])

Server — when the session is on your backend

Useful when assignments are resolved server-side (feature-flag service, SSR rollout) and you already have the user's sessionId linked (see section 04). Call the endpoint directly:

register-experiments.ts
await axios.post('https://api.sumidata.io/sdk/session-experiments', {
  projectId: process.env.SUMIDATA_PROJECT_ID,
  deviceId: user.sumidataDeviceId,
  sessionId: user.sumidataSessionId,
  experiments: flagService.getAssignments(user.id),
})

Sticky assignments (first-wins)

Sumidata deduplicates on (sessionId, experimentId). The first assignment for a pair is persisted forever — a later call with a different variant for the same experiment in the same session is a no-op (response: { status: "skip" }). This protects analysis from:

  • Flicker-reassignment when a flag evaluator returns different values on re-mount.
  • Double-fires when your SDK reloads the experiment list on route change.
  • Race conditions between client assignment and server assignment.
i
To put a user in a different variant, start a new session. Variants are bound to sessions, not devices — which is what you want for clean A/B math.

Joining experiments to conversions

No special field is needed on the purchase event. The join happens on sessionId at query time:

variant-revenue.sql
SELECT
  se.variant,
  count(DISTINCT e.orderId) AS conversions,
  sum(e.totalAmount)               AS revenue
FROM events e
JOIN session_experiments se ON se.sessionId = e.sessionId
WHERE se.experimentId = 'pricing-v3'
  AND e.orderId != ''
GROUP BY se.variant

The AI Analyst does this join automatically — ask it "revenue by variant for pricing-v3 last 14 days".

09Coupons & discounts

Coupon + discount are separate fields, so reports can break down gross / discount / net without guessing.

Three fields work together to make promo campaigns measurable:

FieldWhat to send
totalAmountNet revenue after the discount. This is what you actually billed the customer
discountAmountMoney taken off the gross price. Sumidata uses this to reconstruct gross = total + discount
couponCodeThe redeemed code ("LAUNCH20", "BLACKFRIDAY"). Aggregates revenue per campaign
with-coupon.ts
await ingest({
  externalUserId: user.id,
  source: 'realtime',
  events: [{
    name: 'purchase',
    orderId: order.id,
    productId: order.productId,
    quantity: 1,
    unitPrice: 99.00,      // gross per unit
    discountAmount: 20.00, // LAUNCH20 = 20% off
    totalAmount: 79.00,    // net, what you charged
    currency: 'USD',
    couponCode: 'LAUNCH20',
  }]
})
i
Send totalAmount as the net — not gross. Sumidata reports show gross, discount, and net as separate columns when you populate both totalAmount and discountAmount. Code-level revenue charts group by couponCode.

Common questions answered by the three fieldsCoupon field FAQ

  • Which code lifted net revenue? — group by couponCode, sum totalAmount.
  • Which code cannibalized margin? — group by couponCode, sum discountAmount.
  • Gross sale value per codetotalAmount + discountAmount.
  • Effective discount rate per campaigndiscountAmount / (totalAmount + discountAmount).

10Error handling & retries

Conversions are revenue — never let one bad event drop the rest of the batch.

Ingest is idempotent by (orderId, productId), so retrying is always safe — a duplicate returns 400 and no event is written. Handle errors per-event, not per-batch:

ingest-safe.ts
async function sendConversion(event: SumidataEvent) {
  try {
    const res = await axios.post(URL, { projectId, externalUserId, source: 'realtime', events: [event] })
    return { ok: true }
  } catch (err: any) {
    const status = err.response?.status
    if (status >= 500 || status === 429) {
      await retryQueue.push(event)
    } else {
      logger.warn('Sumidata rejected event', { orderId: event.orderId, status })
    }
    return { ok: false, status }
  }
}
  • 4xx (validation, including duplicate) — don't retry; log and skip.
  • 5xx — requeue with exponential backoff.
  • Network failure — requeue. Ingest will dedup on retry.
  • Dead-letter queue — after N retries, park the event for manual inspection rather than blocking the pipeline.
i
Ingest is not rate-limited at the moment. That doesn't mean "send as fast as possible" — a runaway producer will saturate your own API egress long before Sumidata pushes back. Stay at a few hundred requests per second per project; for bulk history, see Historical backfill.

11Configuration

Two environment variables. No client keys, no dashboards.

.env
SUMIDATA_PROJECT_ID=proj_…
SUMIDATA_API_URL=https://api.sumidata.io/sdk/ingest
  • SUMIDATA_PROJECT_ID — required. Without it, wrap the ingest call in a no-op and log a warning at boot rather than failing requests.
  • SUMIDATA_API_URL — optional. Override for staging / proxy / on-premise ingest.
i
In non-production environments, leave SUMIDATA_PROJECT_ID unset so local runs and CI don't pollute the production project. Your ingest wrapper should return a success result when disabled.

12The /sdk/* endpoint family

Six endpoints cover the full server-integration surface. Everything takes projectId in the body.

Conversions via /sdk/ingest are the most common use, but the SDK API exposes a small family of endpoints for the full lifecycle — sessions, identity, experiments. All live under the same host and take projectId in the JSON body.

EndpointPurpose
POST /sdk/sessionsCreate a live session. Returns a new sessionId. UTMs captured here seed device attribution.
POST /sdk/sessions/importImport a historical session with a caller-supplied sessionId. Returns 400 if it already exists — safe idempotency.
POST /sdk/replaysCreate a replay bound to an existing session. Required before streaming replay chunks.
POST /sdk/ingestSend events (including conversions), logs, and replay chunks. Main workhorse.
POST /sdk/link-session-to-customerBind a session to an externalUserId. Copies every prior device_attribution row into user_attribution — your multi-touch history is built here.
POST /sdk/session-experimentsRegister A/B variant assignments. Sticky: first-wins per (sessionId, experimentId).
i
When you use the browser SDK, all of these happen automatically — init() creates the session and replay, Sumidata.push('event', …) ingests, identify() links the customer, identifyExperiment() registers variants. The endpoints documented here are what you call from a backend that doesn't have a browser session to lean on.

13AI-agent quick reference

Drop this spec into an agent prompt to give it the full server-ingest surface.

ingest.yaml
base_url: https://api.sumidata.io
auth: projectId (UUID) in JSON body — no Bearer token, no api key header
content_type: application/json

primary_endpoint:
  path: POST /sdk/ingest
  idempotency: dedup key = (orderId, productId); duplicates reject whole batch with 400
  body:
    projectId: UUID, required
    deviceId: UUID, required (zero-UUID ok when no browser session)
    sessionId: UUID, optional for backend sources
    externalUserId: string, optional (stable user ID)
    source: 'realtime' | 'import' | 'backend' (any string accepted, but reports filter on these)
    events: Event[] — keep under 100 per batch
  response: { status: "ok" }

event_fields:
  required: [name]
  conversion_required: [orderId, totalAmount] (+ currency, recommended)
  pricing: [totalAmount, currency, quantity, unitPrice, discountAmount, couponCode]
  product: [productId, productName, productCategory]
  lifecycle: [isPrimarySale, timestamp (ms since epoch)]
  attribution: [utmSource, utmMedium, utmCampaign, utmTerm, utmContent] — auto-resolved from device/user attribution if omitted
  funnel_metadata: [_category, _feature, _surface] — see validation below
  payload: object — any free-form JSON; queryable via JSONExtract at query time

validation:
  _category: awareness | acquisition | activation | revenue | retention | referral
  _surface: marketing_site | app | mobile_app | docs
  _feature: regex ^[a-z0-9_]+$
  error_shape: { "errors": { "events[i].<field>": ["<message>"] } }

related_endpoints:
  - POST /sdk/link-session-to-customer — bind sessionId ↔ externalUserId
  - POST /sdk/session-experiments   — register A/B variants
  - POST /sdk/sessions/import       — import a historical session with caller-supplied UUID
  - GET  /api/partner/events        — read back ingested events (Bearer token)