Guide

Server-side events

Your app is not the only witness. Send events from your own backend — payments, background jobs, webhooks from other providers — and from platforms we ship no SDK for.

When to use this

The iOS SDK reports what happens inside your app. Plenty of things that matter to your product happen somewhere else: a subscription charged overnight, an export that finished on a worker, a webhook from another provider, a user acting on your web app. Those go through /v1/server.

This endpoint is server-to-server. It is not a browser endpoint and not a replacement for the SDK: it has no way to observe app lifecycle, screens, or sessions.

Get a server key

In your project, open Settings → Server keys and create a key. It starts with sk_. Store it in your server configuration; workspace admins can reveal it again from the same page, so a lost key is no reason to rotate one a backend still uses.

A key belongs to one project and accepts every environment: each batch names its own in environment. Keys created before this change stay bound to the environment they were created for.

Worth knowing
A server key is not the key in your app build
The key your app ships with (av_…) may only speak for its own device. A server key may name any install and date an event in the past, so it must never reach a build, a browser, or a public repo. Each endpoint refuses the other kind of key.

Send your first event

curl https://in.useunion.dev/v1/server \
  -H 'x-union-key: sk_your_server_key' \
  -H 'content-type: application/json' \
  -d '{
    "contract_version": 1,
    "batch_id": "9f1c8e34-2b7a-4c51-9d0e-5a6b7c8d9e01",
    "environment": "production",
    "sent_at": 1788000000000,
    "events": [
      {
        "event_id": "1b2c3d4e-5f60-4718-9a2b-3c4d5e6f7081",
        "install_id": "7d8e9f01-2a3b-4c5d-8e9f-0a1b2c3d4e5f",
        "name": "invoice_paid",
        "timestamp": 1788000000000,
        "properties": { "plan": "pro" }
      }
    ]
  }'

A successful call returns 202 with { accepted, rejected, batch_id }.

What a batch looks like

One request carries up to 100 events, and each event carries its own identity — a single call from a cron job usually covers many different people.

{
  "contract_version": 1,
  "batch_id": "<uuid>",
  "environment": "production",
  "sent_at": 1788000000000,
  "context": {
    "os_name": "node",
    "sdk_version": "0.1.0",
    "app_version": "2.3.0"
  },
  "events": [
    {
      "event_id": "<uuid>",
      "install_id": "<uuid>",
      "user_id": "u-42",
      "name": "invoice_paid",
      "timestamp": 1788000000000,
      "properties": { "plan": "pro", "amount_cents": 1900 }
    },
    {
      "event_id": "<uuid>",
      "session_id": "<uuid>",
      "name": "export_finished",
      "timestamp": 1788000000000
    }
  ]
}
FieldRequiredNotes
contract_versionyesAlways 1 today.
batch_idyesUUID per request. Safe to reuse on a retry.
environmentyesproduction, testflight or development. A key created before environments were per batch accepts only the one it is bound to.
sent_atyesWhen your server sent the batch, in milliseconds.
contextnoFree-form origin: os_name, app_version, sdk_version and friends. Anything you leave out stays empty — we never fill it in with a guess.
eventsyes1–100 events.
Event fieldRequiredNotes
event_idyesUUID. We de-duplicate on it, so retries are safe.
nameyesLowercase snake_case, up to 64 characters.
timestampyesWhen the thing happened, in milliseconds.
install_id / session_idone of themWho this is about. See below.
user_idnoYour own id for the person. Links revenue and survey answers to them.
traitsnoUp to 8 short strings describing the person (name, email, plan).
propertiesnoUp to 32 values describing the event.
rolenodiscovery / start / use / success / failure — a hint for feature funnels.

Who the event is about

Every event needs a session_id or an install_id. Without either, the event belongs to nobody: it cannot enter a unique count or a funnel, so we reject it rather than store a number no view can use.

You sendWhat it means
session_idThe event is attached to that session, so it shows up in the feature funnel and on the session timeline.
install_idThe event counts towards that person for the day. Use this when your backend knows the user but not what they were doing in the app.
neitherRejected with 400.

Where the id comes from. Your backend rarely knows an install_id on its own — the app has to hand it over. The iOS SDK exposes it as Union.installId; send it to your server as an opaque string when a user signs in, or alongside whatever request already identifies them. It is stable across launches and reinstalls on the same device, so it keeps pointing at the same person over time. Because it identifies a device, an app that forwards it answers for it in its own App Privacy disclosures, just as it does for identify().

If your backend only knows your own user_id, send it as user_id alongside the id above — on its own it is not enough to attribute the event.

Worth knowing
A server event never changes the state of a session
Passing a session_id does not extend the session, change its duration, reopen a closed one, or show anyone in Live — and server events do not raise your daily active users. That is deliberate: otherwise an event your backend sent two hours after someone closed the app would push a finished session back to “in progress” and leave it there. A nightly job is not an active user.

Timestamps and backdating

timestamp must be within 7 days in the past and 5 minutes in the future. Outside that window the event is rejected with a reason naming the field — we never quietly move it to “now”, because that would shift it to a different day and break the very number you sent it to produce.

An event dated before today is accepted, and the day it belongs to is recalculated overnight. Expect it to appear in your charts the next day rather than immediately.

This is not a path for importing years of history from another analytics tool — the window is too narrow on purpose.

Event names and limits

Names match ^[a-z][a-z0-9_]*$ and are at most 64 characters. Names starting with $ are reserved for the automatic events the SDK sends about app lifecycle, which a server cannot observe.

Up to 100 events and 256 KB per request, 32 properties per event, strings up to 256 characters. Split larger payloads across requests.

Responses

StatusMeaningWhat to do
202Accepted.Nothing. The body tells you how many events were accepted.
400The batch or an event was rejected.Read details[].path — it points at the exact event and field. Fix and resend; do not blind-retry.
401Missing, unknown, or wrong kind of key.Check you are using a server key, not the key from your app build.
403Collection is disabled for this key.The key was revoked in Settings.
413Batch too large.Split it and resend.
5xxOur side.Retry with backoff, keeping the same event_id — we de-duplicate on it.

Privacy

We do not read the IP address of the calling server, and events sent this way carry no country or region: the address belongs to a datacentre, not to a person, so recording it would invent a dimension rather than measure one.

If your project runs in Strict Anonymous mode, identity is refused here exactly as it is in the SDK — such a project can send server events only with a session_id.

Next

Create a key in your project settings and send the call above. If something comes back 400, the details array names the field — that is usually faster than reading this page twice.