Server-side capture

Send events and identifies from your backend: purchases, renewals, refunds, background jobs, anything that your app's SDK can't see. Server events go to the same Vexo app as your mobile and web events, and they belong to the same people.

You can call the HTTP API directly, or use the Node.js or Python SDK, which batch and retry for you.

How server events work

  • People. Every message names a person with a distinctId. Use the same ID that you pass to identifyDevice() in your app, such as the user's email or account ID. Vexo keeps one server device for each distinct ID in each app, identified with that ID, so server events and traits belong to the same person as the app installs identified with it. In People, that device shows as Server.
  • Exact IDs. Distinct IDs match exactly, including case and accents: CaseUser and caseuser are two server devices, and so are jose and josé. Send each person's ID the same way every time.
  • Custom events. A server event is a custom event: it appears in your custom events, dashboards, and funnels like one that customEvent() sends, with the route server.
  • No session or location. A server event isn't part of a session or a replay, has no location (the request comes from your server, not the user's device), and doesn't count as an online user.
  • Mobile event store. Server events are stored with your app's mobile events. Web dashboards don't show them yet.
  • Billing. Each server event that Vexo stores counts toward your usage, your app's events cap, and the free-plan limit, exactly like an event from the SDK. Identifies don't count.

Security

The capture API uses your app's API key, the same key that your app SDK embeds in every install. That key isn't a secret: anyone who has a copy of your app can extract it.

With the key, anyone can capture events and identify traits for any distinct ID in your app, from the API or from the app SDK. That's the same model as the public-key capture APIs of PostHog, Mixpanel, and Amplitude. The capture API can't read any data.

Keep that in mind when you choose what to send:

  • Don't send secrets, tokens, or anything you must keep private as properties or traits.
  • Don't rely on server-set traits for decisions that matter, such as access or pricing: someone with the key can overwrite them.
  • Set an events cap on the app to bound what someone with the key can add to your usage.

HTTP API

Send POST requests to https://api.vexo.co with a JSON body of up to 5 MB and these headers:

  • X-Api-Key: your app's API key. Copy it from the app's settings.
  • Content-Type: application/json

Capture an event

Endpoint: POST /v1/capture

curl -X POST 'https://api.vexo.co/v1/capture' \
  -H 'X-Api-Key: YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "distinctId": "user@example.com",
    "event": "purchase_completed",
    "properties": { "amount": 49.9, "currency": "USD" },
    "timestamp": "2026-11-07T12:00:00Z",
    "messageId": "5b0c8f0e-6d1e-4f62-9d55-3f1f0f6a2b71"
  }'
FieldRequiredDescription
distinctIdYesThe person's ID: a string of up to 200 characters, matched exactly.
eventYesThe event name: a string of up to 255 characters.
propertiesNoA JSON object of event properties.
timestampNoWhen the event happened: epoch milliseconds (a fraction is dropped), or an ISO 8601 date-time with an offset (Z or +02:00). It can't be earlier than 2000-01-01. Defaults to the time Vexo receives the event.
messageIdNoA unique string for the message. Send it so that a retried message is stored once.

Identify a person

Merges traits into the person. A trait set to null is removed.

Endpoint: POST /v1/identify

curl -X POST 'https://api.vexo.co/v1/identify' \
  -H 'X-Api-Key: YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "distinctId": "user@example.com",
    "traits": { "plan": "pro", "company": "Acme" }
  }'
FieldRequiredDescription
distinctIdYesThe person's ID: a string of up to 200 characters, matched exactly.
traitsNoA JSON object of traits.

Send a batch

Sends up to 500 messages in one request. Each message has the fields of a capture or an identify, plus type: capture (the default) or identify.

Endpoint: POST /v1/batch

curl -X POST 'https://api.vexo.co/v1/batch' \
  -H 'X-Api-Key: YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "batch": [
      { "type": "identify", "distinctId": "user@example.com", "traits": { "plan": "pro" } },
      { "type": "capture", "distinctId": "user@example.com", "event": "plan_upgraded", "properties": { "from": "free" } }
    ]
  }'

Responses

A request that Vexo accepts returns 200 with a result for its messages. Every message that isn't listed in dropped or failed was accepted. index is the message's position in the request, from 0.

{
    "success": true,
    "code": 200,
    "message": null,
    "data": {
        "accepted": 1,
        "dropped": [
            { "index": 1, "reason": "event is required" }
        ],
        "failed": [
            { "index": 2, "reason": "Storing failed; retry this message" }
        ]
    }
}
  • dropped: messages that Vexo won't store, with the reason. Don't retry them; fix the message instead. A message is dropped when:
    • It isn't a JSON object, or a field has the wrong JSON type (for example, properties that isn't an object, or a distinctId that isn't a string).
    • distinctId or event is missing or too long, or type isn't capture or identify.
    • timestamp isn't epoch milliseconds or ISO 8601 with an offset, or it's earlier than 2000-01-01.
    • The event is over the size limit (see Limits).
  • failed: messages that Vexo couldn't store because of a temporary error, such as a database timeout for that person. Retry them with backoff and the same messageId. The other messages of the request aren't affected.

Errors that fail the whole request return the same envelope with success: false. Except after a 5xx, Vexo stored none of the request's messages:

StatusMeaningRetry?
400The body isn't valid JSON, /v1/batch has no batch array, or the batch is empty or has more than 500 messages.No
401The X-Api-Key header is missing or the key isn't valid.No
406The app reached its events cap, or your account reached its event limit. See Usage and Limits.No
413The body is over 5 MB. Send fewer messages in each batch.No
429The app sent more than 1,200 requests in a minute. Wait for the number of seconds in the Retry-After header.Yes
5xxA temporary server error. Messages that were already stored are deduplicated on retry by messageId.Yes, with backoff

When you retry, send the same messages with the same messageId values. Vexo stores a message ID once within 5 days, so a retry never double-counts.

Limits

LimitValue
Request body5 MB
Messages per batch500
Requests1,200 a minute for each app
Event name255 characters
Event properties65,514 characters, measured as compact JSON (65,536 for the stored event, minus the 22 characters of the server route that Vexo adds)
distinctId200 characters
TimestampsNo earlier than 2000-01-01. A timestamp more than 1 hour ahead of the server's clock is set to the time Vexo receives the event.

Node.js

The vexo-node package works on Node.js 18 or later and has no dependencies. It queues messages, sends them in batches when 100 are queued or every 10 seconds, retries network errors, 429, and 5xx responses (and the messages in failed) with exponential backoff, and keeps each request under 5 MB.

npm install vexo-node
const { Vexo } = require('vexo-node');

const vexo = new Vexo(process.env.VEXO_API_KEY);

vexo.identify({ distinctId: 'user@example.com', traits: { plan: 'pro' } });
vexo.capture({
    distinctId: 'user@example.com',
    event: 'purchase_completed',
    properties: { amount: 49.9, currency: 'USD' },
});

// Send everything still queued before the process exits.
process.on('SIGTERM', async () => {
    await vexo.shutdown();
    process.exit(0);
});

capture() and identify() only queue the message, so they never slow down your request handlers. A script that ends on its own calls shutdown() when its event loop empties; a server that stops on a signal must call await vexo.shutdown() first. shutdown() gives up after shutdownTimeout, drops what is still queued, and reports it to onError.

OptionDefaultDescription
hosthttps://api.vexo.coAPI base URL.
flushAt100Send a batch when this many messages are queued (1 to 500).
flushInterval10000Send whatever is queued every this many milliseconds.
maxRetries3Retries for network errors, 429, and 5xx responses.
maxQueueSize10000Messages kept in memory; new messages are dropped once it's full.
timeout10000Request timeout in milliseconds.
shutdownTimeout10000How long shutdown() keeps sending, in milliseconds.
onErrorconsole.errorCalled with send failures and with the messages that Vexo dropped.

A message that JSON.stringify can't serialize, such as one with a BigInt property, is dropped when you call capture() and reported to onError; the rest of the queue isn't affected.

Python

The vexo-python package works on Python 3.8 or later and uses only the standard library. It sends batches from a background thread when 100 messages are queued or every 10 seconds, retries network errors, 429, and 5xx responses (and the messages in failed) with exponential backoff, keeps each request under 5 MB, and sends its queue when the interpreter exits. It's safe to create before a fork (gunicorn --preload, Celery prefork): each child process starts its own sending thread.

pip install vexo-python
import os
from vexo import Vexo

vexo = Vexo(os.environ["VEXO_API_KEY"])

vexo.identify("user@example.com", {"plan": "pro"})
vexo.capture("user@example.com", "purchase_completed", {"amount": 49.9, "currency": "USD"})

# Optional: send everything still queued now. It also runs when the interpreter exits.
vexo.shutdown()

shutdown() gives up after shutdown_timeout, drops what is still queued, and reports it to on_error. Timestamps are sent as epoch milliseconds; a naive datetime is read as UTC. Values that JSON can't encode, such as Decimal, UUID, or datetime properties, are sent as strings.

OptionDefaultDescription
hosthttps://api.vexo.coAPI base URL.
flush_at100Send a batch when this many messages are queued (1 to 500).
flush_interval10.0Send whatever is queued every this many seconds.
max_retries3Retries for network errors, 429, and 5xx responses.
max_queue_size10000Messages kept in memory; new messages are dropped once it's full.
timeout10.0Request timeout in seconds.
shutdown_timeout10.0How long shutdown() keeps sending, in seconds.
on_errorLog to the vexo loggerCalled with send failures and with the messages that Vexo dropped.

Both SDKs give every message a messageId, so a retried batch is stored once. After a batch uses up its retries, the next batches get a single attempt until a request succeeds, so an unreachable API doesn't hold up your process. Neither SDK raises an error from capture() or identify(): failures go to the error callback.