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. Browser checkouts use Sumidata.purchase() instead - see Conversions.

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)
IdempotencyorderId per project
POST

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

Accepts one envelope per request. The envelope carries identity (deviceId, optional sessionId, optional externalUserId) and any of events, logs, replayEvents, networkRequests. 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
sourcestring-Origin tag, stored on each event. Recommended values: realtime · import · backend. Any string is accepted; omitted is treated as sdk - stick to the three above
sessionIdUUID-Browser session ID captured from the SDK. Required when source is omitted (treated as sdk); optional for backend sources. Setting it ties the event to that session (Error Impact report, sessionId joins). UTMs are inherited through deviceId and externalUserId, not sessionId
replayIdUUID-Required only for browser SDK ingest when replayEvents are sent. Omit for backend sources
externalUserIdstring-Stable 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. Each entry: level, message, source, payload, url, tags (string[]), timestamp (Unix ms)
replayEventsobject[]-Browser SDK only - a replay chunk; needs replayId. Ignored for backend sources
networkRequestsobject[]-Browser SDK only - captured network calls, at most 2000 per request
behaviorScorenumber-Browser SDK only - automation score from 0 to 1
behaviorSignalsobject-Browser SDK only - must be an object when sent
i
No hard batch limit is enforced on events; the request body as a whole is capped at 64 MB. 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":"YOUR_PROJECT_ID","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 { status: "ok" }. Valid events are written synchronously, so a 200 (other than quota_exceeded, below) means every valid event in the batch landed in ClickHouse. Events that fail per-event validation are not written - they are returned in a skipped array next to the status: { "status": "ok", "skipped": [{ "index": i, "name": "…", "errors": { "<path>": ["<message>"] } }] }. Items dropped from an order that was still stored are listed in a warnings array: { "warnings": [{ "index": i, "orderId": "…", "code": "item_invalid" | "items_over_limit" | "items_without_order", "dropped": n, "message": "…" }] }. Both arrays appear only when non-empty. The other possible success payloads are { 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 - and { status: "quota_exceeded" }: your plan's monthly session quota is used up and nothing in the request was stored.

Errors

Envelope-level validation returns 400 with a field-indexed object: { "errors": { "<path>": ["<message>"] } }; request-level messages use the _general key. These reject the whole request:

400Invalid JSON bodyThe body is not valid JSON
400projectId requiredMissing projectId, or it is not a string
400Invalid projectNo project has this projectId
400deviceId is requiredMissing deviceId in the envelope
400Invalid format: "<value>" is not a valid UUIDdeviceId is not a valid UUID. Zero-UUID is allowed
400deviceId and sessionId are required for SDK sourcesource is omitted or equals sdk and sessionId is missing
400All IDs must be valid UUIDssource is omitted or equals sdk and sessionId or replayId is not a valid UUID
400replayId is required when replayEvents are presentsdk source with replayEvents but no replayId
400sessionId must be a valid UUID if providedBackend source with a malformed sessionId
400replayId must be a valid UUID if providedBackend source with a malformed replayId
400behaviorScore must be a number between 0 and 1 if providedSDK-only field behaviorScore is not a number from 0 to 1
400behaviorSignals must be an object if providedSDK-only field behaviorSignals is not an object
400networkRequests must be an array if providedSDK-only field networkRequests is not an array
400networkRequests exceeds maximum batch size (2000)More than 2000 networkRequests in one request

Per-event problems do not return 400. The offending event is dropped and reported in the skipped array of an otherwise 200 response, while every valid event in the same batch is still stored:

  • events[i]._category - not one of awareness, acquisition, activation, revenue, retention, referral.
  • events[i]._surface - not one of the 98 named surfaces (see the Product events guide).
  • events[i]._feature - not a string, or characters outside [a-z0-9_].
  • Duplicate conversion: <orderId> - an order with the same orderId was already accepted, or appears earlier in the same batch. Only that event is dropped; the first order stays unchanged.
  • Send products inside items[]: one event per order - the purchase carries productId, productName, productCategory, quantity or unitPrice at the top level. Send one event per order with the products in items.
  • orderId must be a string or a number - orderId is an object, an array or true.
i
A single bad or duplicate event no longer loses the rest of the batch - it is dropped and listed in skipped. Inspect that array to confirm which events stored. See Historical backfill.

02The source field

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

Set a source on every envelope - when it is omitted the request is treated as sdk. Any string is accepted and stored on each event: the dashboard's Events filter offers backend and import, and the Partner API filters on any value. Stick to the three canonical values below 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 a replayId when replayEvents are sent).
i
The server also uses source to relax validation: when source !== 'sdk', sessionId and replayId become optional (and replayEvents are ignored). 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 have no URL to read UTMs from. When the browser SDK has identified the user, a conversion with that externalUserId already inherits their latest campaign touch (section 07). To also cover visitors who were never identified, carry the browser IDs to your server in three steps:

1

Read the browser session + device IDs

After login, grab both IDs from the SDK. deviceId lets the conversion inherit the device's UTM attribution; sessionId ties it to the current browser session. Both are 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 - missing UTMs are filled in from the user's attribution, or from the device's when the user has none (see section 07), 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 deviceId present, a conversion without its own UTMs inherits the latest campaign touch recorded for that device (a match on externalUserId wins - see section 07) - even if the payment confirmation arrives minutes later via webhook. sessionId ties the conversion to the browser session itself.

05Full purchase payload reference

The full event schema. An orderId is what makes an event a purchase - send name and totalAmount with it.

Identifiers

NameTypeRequiredDescription
namestringyesEvent name - purchase for revenue events
orderIdstringyesUnique order identifier within the project - the dedup key. A number is accepted and stored as its string form
itemsItem[]-Products of the order, at most 200. See Items below. Omit or send [] for an order without products (a renewal, a top-up)
sourcestring-Overrides the envelope source for this event
urlstring-Page URL the event belongs to, if any

Items

NameTypeRequiredDescription
productIdstringyesSKU, plan ID, or asset identifier. An item without it is dropped with a warning
productNamestring-Human-readable product name for reports
productCategorystring-Your category taxonomy (e.g. subscription, addon, one-off)
quantitynumber-Units purchased, a whole number from 1. Defaults to 1
unitPricenumber-Price per unit in major units, in the order's currency. Defaults to 0. Never changes the order's revenue
!
Items are dropped - never the order - when productId is missing, when quantity is not a whole number from 1 to 4294967295 or unitPrice is negative, 10^16 or more, or not a number, and past the 200th valid item. Each drop is reported in warnings. Product fields at the top level of a purchase are not accepted: the whole event is skipped.

Pricing

NameTypeRequiredDescription
totalAmountnumber-Net revenue in major units (e.g. 79.99). Send the amount after discount. Without it the order is recorded with revenue 0
currencystring-ISO 4217 code, stored as sent - not validated or converted. One per order - items inherit it
discountAmountnumber-Discount applied, in major units. Keep as a separate field - it is stored with the order and shown on the event card; reports use totalAmount only
couponCodestring-Promo code redeemed on this order

Lifecycle

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

Attribution (optional - auto-resolved for conversions)

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

Event metadata (AAARRR funnel tagging)

NameTypeRequiredDescription
_categoryenum-awareness · acquisition · activation · revenue · retention · referral. Invalid values skip the event
_surfaceenum-One of 98 named surfaces - see the Product events guide. Invalid values skip the event
_featurestring-Lowercase alphanumeric and underscores only ([a-z0-9_]+)

Free-form

NameTypeRequiredDescription
payloadobject-Any fields not listed above go here - other top-level fields are not stored. Stored as JSON, available in queries via JSONExtract* functions
full-event.json
{
  "name": "purchase",
  "orderId": "ord_01HW…",
  "totalAmount": 79.00,
  "currency": "USD",
  "discountAmount": 20.00,
  "couponCode": "LAUNCH20",
  "isPrimarySale": true,
  "timestamp": 1713609164000,
  "items": [{
    "productId": "plan_pro_monthly",
    "productName": "Pro (monthly)",
    "productCategory": "subscription",
    "quantity": 1,
    "unitPrice": 99.00
  }],
  "_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 the Primary / Secondary Sales split (new-vs-returning revenue) in the Sales Report, and is what you need for first-purchase LTV cohorts and acquisition CAC math in your own queries. 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. It is filled each time the user is identified on a device, from that device's campaign history.
  3. Device attribution - latest entry in device_attribution for this deviceId. Used when the event has no externalUserId or the user has no attribution entry 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 user's latest campaign touch.

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, events 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
projectIdUUIDyesYour 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
{ "status": "quota_exceeded" }   // monthly session quota used up - nothing registered

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 the one that sticks - 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.
  • A client-side and a server-side assignment for the same session that arrive one after the other - two requests that arrive at the same moment can both be stored.
i
Variants are bound to sessions, not devices - a new session can carry a different variant. But the Experiments dashboard compares variants per device and leaves out a device that saw more than one variant of an experiment, so keep a returning user in the same variant.

Joining experiments to conversions

No special field is needed on the purchase event. In your own queries the join happens on sessionId at query time (the Experiments dashboard matches by deviceId instead):

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 queries 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. Gross = total + discount
couponCodeThe redeemed code ("LAUNCH20", "BLACKFRIDAY"). Group by it to aggregate revenue per campaign
with-coupon.ts
await ingest({
  externalUserId: user.id,
  source: 'realtime',
  events: [{
    name: 'purchase',
    orderId: order.id,
    items: [{ 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. Reports use totalAmount only, so revenue is net; discountAmount and couponCode are stored with the order, shown on the event card, and available to your own queries (for example through the AI Analyst).

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 code - totalAmount + discountAmount.
  • Effective discount rate per campaign - discountAmount / (totalAmount + discountAmount).

10Error handling & retries

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

Ingest is idempotent by orderId, so retrying a request that failed is safe - a repeat of an order that was already accepted is skipped, returned in response.skipped, and never written twice. Handle errors per-event, not per-batch:

ingest-safe.ts
async function sendConversion(event: SumidataEvent) {
  try {
    const res = await axios.post(URL, { projectId, deviceId, 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 }
  }
}
  • 200 with skipped - invalid or duplicate events were dropped per-event; inspect response.skipped, don't retry the batch.
  • 200 with status: "quota_exceeded" - your plan's monthly session quota is used up and nothing was stored; resend once quota is available again.
  • 4xx (envelope errors - bad deviceId, missing IDs) - don't retry; fix the request.
  • 5xx - requeue with exponential backoff. Exception: a projectId that is not a UUID, or a body over 64 MB, also returns 500 - fix the request instead.
  • 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=YOUR_PROJECT_ID
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. The browser SDK also calls /sdk/chat/* (chat widget) and /sdk/heatmaps/screenshot (heatmap snapshots) - those are not for server use.

EndpointPurpose
POST /sdk/sessionsCreate a live session - with a caller-supplied sessionId or a generated one. Returns the 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. In the browser, the SDK sends orders here through purchase().
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 product events, Sumidata.purchase() / Sumidata.push('purchase', [order]) records orders, identify() links the customer, identifyExperiment() registers variants. An event('purchase', …) call does not record an order. 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 per project; a repeat (stored or earlier in the same batch) is skipped and returned in response.skipped
  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; omitted = sdk, which requires sessionId)
    events: Event[] - keep under 100 per batch
  response: { status: "ok", skipped?: [{ index, name, errors }], warnings?: [{ index, orderId, code, dropped, message }] }
  other_responses: { status: "replay_full", reason: "size_limit" } (replay chunk over 50 MB; events still stored) · { status: "quota_exceeded" } (monthly session quota used up; nothing stored)

event_fields:
  required: [name]
  conversion_required: [orderId] (+ totalAmount - revenue 0 without it; currency recommended)
  pricing: [totalAmount (order net, the only revenue source), currency, discountAmount, couponCode]
  items: Item[] on the purchase, max 200; Item = { productId (required), productName, productCategory, quantity (int 1–4294967295, default 1), unitPrice (≥ 0 and < 10^16, default 0) }; invalid or extra items are dropped with a warning, the order is kept
  one_event_per_order: product fields at the top level of a purchase → event skipped ("Send products inside items[]: one event per order")
  lifecycle: [isPrimarySale, timestamp (ms since epoch)]
  other: [source (overrides the envelope source for this event), url]
  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: one of 98 named surfaces (see Product events guide)
  _feature: regex ^[a-z0-9_]+$
  error_shape: 400 { "errors": { "<field>" | "_general": ["<message>"] } } - envelope only; per-event problems go to skipped
  skipped_shape: { "status": "ok", "skipped": [{ "index": i, "name": "…", "errors": { "<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)