Server Events API
The Server Events API is Apex's server-to-server event ingest. It sits alongside the Web SDK (browser snippet) and Mobile SDK (Capacitor plugin); together they make up Apex's first-party data collection surface.
The Server Events API lets your backend send events to Apex without going through the browser snippet or a mobile SDK. Use it for:
- Stripe / billing webhooks — fire a
purchase_completedwhen an invoice pays, regardless of whether the user was identified in the browser. - CRM-side conversions — when your CRM (HubSpot, Salesforce, …) qualifies a lead as
customer, send the event server-side so Apex stitches it to the original visitorId. - Custom Lambda / cron workflows — anything happening in your backend that's a meaningful conversion.
Endpoint
POST /api/v1/events
Authentication: an org-scoped server API key (apex_sk_…) from Settings → API Keys.
curl -X POST https://app.apex.inc/api/v1/events \
-H "x-api-key: apex_sk_your_key_here" \
-H "idempotency-key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{
"events": [
{
"type": "purchase_completed",
"visitorId": "vis_abc123",
"data": {
"value": 99,
"currency": "USD",
"order_id": "order_42"
}
}
]
}'
Response
The Server Events API always responds with { data, error, metadata } — the standard Apex API shape.
{
"data": { "received": 1, "duplicates": 0, "outsideProduction": 0, "errors": [] },
"metadata": {
"workspaceKey": "prj_yourkey",
"idempotencyKey": "550e8400-e29b-41d4-a716-446655440000",
"batchSize": 1
}
}
Contract rules
- Batch cap: 100 events. Larger batches return
400 batch_too_large. Shard yourself. - Idempotency: send
idempotency-keyon every batch. Webhook retries are normal and you don't want them counted twice. See Retries and duplicates. x-apex-workspaceheader. Only required when your org has multiple workspaces and Apex can't auto-pick one.- Authorization header is accepted too:
Authorization: Bearer apex_sk_…is equivalent tox-api-key.
Retries and duplicates
Apex has three layers of duplicate protection. Use the first two on every webhook handler.
The idempotency-key header (per batch).
- Scope: your workspace plus the key. The same key in two different workspaces is two different batches. The event type is not part of it.
- How long: Apex remembers a key for at least 24 hours.
- A repeat: the whole batch is skipped and nothing is written. The response is
200with{ "received": 0, "duplicates": <batch size>, "deduped": true }. - Same key, different data: still skipped. The first batch stands, and Apex does not compare the two. Give each distinct batch its own key.
- A failed write: if a revenue write in the batch fails, the response is
500 revenue_write_failedand the key is not remembered, so your retry goes through.
eventId on each event (per event).
- Set it to something stable from your system, such as
order-123-placed. Apex counts eacheventIdonce per workspace for at least 24 hours, even when the retry arrives under a differentidempotency-keyor in a different batch. - Repeats are skipped and counted in
duplicates. The rest of the batch is written normally. - As with the batch key, an event whose revenue write failed is not remembered, so it can be retried.
order_id on purchases. A purchase is counted once per order_id in data. The first one with a positive amount wins. This has no time limit.
A retry more than 24 hours later is caught by order_id for purchases, but not by the other two layers. For anything else that has to be counted exactly once, retry within a day.
Staging and test backends
One server key can serve every deployment. Tell Apex which one sent the event:
- Per deployment: set the
x-apex-environmentheader toproduction,betaordev. Put it in each deployment's config once. - Per event: set
environmenton the event. It wins over the header.
Apex also accepts staging and preview for Beta, and test, development and localhost for Dev. Anything else is rejected: a misspelled header returns 400 invalid_environment, and a misspelled event comes back in errors with invalid_environment. A typo never quietly counts as Production.
Beta and Dev events, and events with "testMode": true, are stored and show on the dashboard under their environment. They never count as revenue and are never sent to your ad platforms. The response counts them in outsideProduction.
An event with no environment shows as Unclassified on the dashboard, but it still counts as revenue and can be sent to your ad platforms. If your staging backend sends purchases, give it the header.
When a person declines tracking
Pass the person's answer from your consent banner or Apple's tracking prompt as consent on the event:
{ "type": "purchase_completed", "email": "jo@example.com", "consent": "denied", "data": { "value": 99, "currency": "USD", "order_id": "order_42" } }
"denied": the event still counts in your reports and revenue. Apex never sends it to an ad platform."granted": Apex may send it to the ad platforms you've connected, under your workspace's consent settings.- Left out: purchases and subscriptions go out, because they are your own sales. Signups and leads follow your workspace's default for events with no answer.
Any other value comes back in errors with invalid_consent.
Where the person is
Your server sends from its own location, so Apex never guesses a country for a server event. Pass the person's country as a two-letter code (ISO 3166), from the order's address or the country your edge saw when they signed up:
{ "type": "purchase_completed", "email": "jo@example.com", "country": "DE", "data": { "value": 99, "currency": "EUR", "order_id": "order_43" } }
- If your workspace asks for consent only where the law requires it, a sale or lead from a country outside those places goes to your ad platforms, and one with no country is held back.
- When the event belongs to someone Apex already knows, the country is kept on them, and it decides whether they can join your ad audiences.
- A value that isn't a two-letter code (
"Germany") doesn't stop the event. It counts as usual, the country is treated as unknown, and Apex lists it under data issues on your Integrations page.
What Apex does with an email
Put the email at the top of the event ("email": "jo@example.com") or in data.email. Apex uses it to find the person, so the sale lands on the right customer.
When a sale is sent to your ad platforms, the email goes with it so the platform can match the buyer. It is hashed before it leaves Apex. An event marked "consent": "denied" is never sent, so its email is only used to find the person.
How the Server Events API fits the rest of Apex
| SDK | Where it runs | What it sees |
|---|---|---|
Web SDK (apex.js snippet + @apex-inc/sdk) | Browser tab | Anonymous visitors, identifies, custom events, web purchases |
| Mobile SDK (Capacitor plugin) | Native iOS / Android | App installs, in-app purchases, device-level identifiers |
| Server Events API (this) | Your backend | Server-side conversions, webhook-driven events, CRM milestones |
The same visitorId works across all three. When the Server Events API receives an event with a visitorId that the Web SDK previously created, the events show up on the same person in the Conversion Loop view.
OpenAPI spec
The machine-readable spec for the whole Apex API, this endpoint included, is at https://app.apex.inc/api/openapi?format=yaml (drop ?format=yaml for JSON). Import that link into Postman or Insomnia, or point the openapi-generator CLI at it to scaffold a client in your language.
Dashboard surface
When events arrive via the Server Events API, the Server Events health card lights up on /dashboard/settings/workspace showing recent volume, last-seen timestamp, and any errors. If the card stays grey for a workspace whose API key was issued, the typical cause is a wrong workspaceKey header or a revoked key.