Server-side events
Send events from your backend with a secret key, flush them in any runtime, and make retries and repeated webhooks count once.
Server-side events are events your backend sends with the secret key of a server source: payments, signups, webhooks, jobs. Use them for what happens on the server, and for events that must arrive even when a browser blocks scripts or closes the tab. Browser behaviour, such as pageviews and clicks, stays with a browser SDK. For the request format, see Send events.
When to send from a server
- The server knows it first: a payment confirmed by your provider's webhook, an invoice paid, a subscription renewed, a job finished.
- It has to be exact: revenue and conversions. A browser event is lost to ad blockers, closed tabs and failed networks; a server event is retried and can be sent exactly once.
- There is no browser: APIs, CLIs, background workers.
Secret key
A server source has one kind of key, the secret key (mf_…). See Keys for where to create it. Put it in MIRAFIVE_SECRET_KEY:
MIRAFIVE_SECRET_KEY=mf_…Rules:
- The secret key travels only in the
Authorization: Bearerheader. The SDKs send it there for you. - It never reaches a browser: not in HTML, not in a JavaScript bundle, not in a mobile app.
@mirafive/sdk-serverthrows when created wherewindowanddocumentexist, and the browser SDK refuses asecretKeyoption. - A secret key that arrives with an
OriginorSec-Fetch-Siteheader, which browsers send, is marked exposed. Events are still accepted, so the leak shows up in MIRA FIVE rather than as lost data, but flag requests are refused with403 secret_key_exposed. Rotate the key. - A secret key in a URL path is refused (
403 secret_key_in_path), and a website key sent as a bearer is refused (403 website_key_as_bearer). @mirafive/sdk-serveraccepts onlyhttps://hosts, andhttp://forlocalhost,127.0.0.1and[::1], since the key is in a header.
Set up
Create one client per process and reuse it:
import { Mira } from '@mirafive/sdk-server'
export const mira = new Mira({ key: process.env.MIRAFIVE_SECRET_KEY })mira.track('signup', { userId: user.id, properties: { plan: 'pro' } })Works in Node 20 and later, Bun, Deno, Cloudflare Workers and edge runtimes. See Node.js and edge.
Mode
Server clients default to full mode: events may carry userId, anonymousId and sessionId. You hold the consent or other lawful basis for them. Use your own pseudonymous user id, never an email address.
With mode: 'consentless' (Mode::Consentless in PHP, MIRAFIVE_MODE=consentless in Laravel), events carry no ids, and passing one throws: a TypeError in Node.js, an InvalidArgumentException in PHP. Use it to count without identifiers, for example invoices or anonymous totals.
The server source's own mode is a ceiling: a consentless source refuses full batches with 400 collection_mode_not_allowed. Server events do not follow the browser's mode: a site whose script tag runs consentless can still send full server events.
Batching and flushing
track() and identify() add events to a buffer; the buffer is sent as one batch. send() skips the buffer, sends at once and returns the server's receipt.
| SDK | The buffer is sent |
|---|---|
@mirafive/sdk-server | 1 second after the first event (flushAfterMs), at 100 events (flushAt), and on flush() or shutdown() |
| PHP | At 100 events (flushAt), on flush(), and when the PHP process finishes the request |
| Laravel | When the app terminates (after the response under PHP-FPM), after each Octane request and each queued job, and at 100 events |
| Symfony | On kernel.terminate, console.terminate and each service reset, and after each Messenger message in messenger:consume |
Long-running processes
A Node.js, Bun or Deno server flushes on its timer. The timer does not keep the process alive, so flush before it exits:
process.on('SIGTERM', async () => {
await mira.shutdown()
process.exit(0)
})In a script or CLI, await mira.flush() at the end. PHP workers that serve many requests (Octane, RoadRunner, Swoole, FrankenPHP worker mode, queue workers) must flush after each request or job, or events wait until the worker stops. The Laravel and Symfony packages do this; with the plain PHP SDK, call $mira->flush() yourself.
Serverless and edge
A function that returns before its events are delivered loses them. Pick one:
import { Mira } from '@mirafive/sdk-server'
let mira: Mira | undefined
export default {
async fetch(request: Request, env: { MIRAFIVE_SECRET_KEY: string }, ctx: ExecutionContext): Promise<Response> {
mira ??= new Mira({ key: env.MIRAFIVE_SECRET_KEY })
mira.track('api_call', { properties: { path: new URL(request.url).pathname } })
ctx.waitUntil(mira.flush())
return new Response('ok')
},
}In PHP, delivery runs at the end of the request and a flush spends at most 3 seconds (flushDeadlineMs). To take it out of the request entirely, hand batches to a queue: MIRAFIVE_QUEUE in Laravel, messenger in Symfony, or the handOff option of the PHP SDK.
Idempotency
Every batch has a batch id, and the server stores a batch id once per day, however often it arrives. Retries resend the byte-identical body, so a retried batch never counts twice.
For events that may be sent twice by your own code, such as a webhook your payment provider delivers again or a job that runs twice, pass an idempotency key to send(). Name it after what the event is about:
const receipt = await mira.send(
[{ name: 'order completed', userId: order.customerId, properties: { revenue: 129, currency: 'EUR' } }],
{ idempotencyKey: `order-${order.id}` },
)The SDK derives the batch id from the key, the same way in every language:
batch = UUIDv8( SHA-256( "mirafive:batch:" + key ) )It takes the first 16 bytes of the digest, sets the version nibble to 8 and the variant bits to 10. So order-981 names the same batch from Node.js, PHP or curl, and a second send within a day is counted and stored once. The key must be a non-empty string. Send the same events under the same key.
In Node.js, to resend byte-identically from another process (a durable outbox), also fix each event's time and pass sentAt: send(events, { idempotencyKey, sentAt }).
Retries
The SDKs retry what can succeed later, with the same body, and give up on what cannot:
| Answer | Retried |
|---|---|
408, 429 (after Retry-After), 5xx, network error, timeout | Yes |
400, 401, 403 | No |
413 payload_too_large | No. Buffered batches are split to fit |
@mirafive/sdk-server | PHP | |
|---|---|---|
| Retries | 3 (maxRetries) | 2 (maxRetries) |
| Backoff | Full jitter, up to 4 seconds | Up to 1 second |
Retry-After | Honoured, up to 30 seconds for a buffered flush and 2 minutes for send() | Honoured up to 3 seconds (maxRetryAfterMs); longer gives up |
| Per attempt | 10 seconds (timeoutMs) | 5 seconds, 1 second to connect |
When a buffered batch is refused with 400 validation_failed, the SDK reports the events the server named, drops them and resends the rest, so one bad event does not cost its batch. PHP also pauses delivery for 30 seconds after a flush fails for a retryable reason, and drops the events of flushes in that window, so an outage does not slow every request; pass a PSR-16 cache to share that pause between PHP-FPM processes.
Link to the browser visitor
A server event joins the visitor's browser events when it carries the same anonymous id. Read it in the page with anonymousId(), send it with your request, and pass it on:
mira.identify(user.id, { plan: user.plan }, { anonymousId: request.headers.get('X-Anonymous-Id') ?? undefined })
mira.track('order completed', { userId: user.id, properties: { revenue: 49.9, currency: 'EUR' } })The anonymous id exists only in full mode and after statistics consent. Identify users shows the browser side and the PHP and Laravel versions.
Errors
track() and identify() never throw for transport reasons: failures go to onError. send() rejects (Node.js) or throws (PHP) with a MiraError:
| Field | Meaning |
|---|---|
code (errorCode in PHP) | The server's code (validation_failed, unauthorized, rate_limited, …) or network_error, timeout, aborted, invalid_event, unexpected |
status | The HTTP status, when an answer came. PHP's getCode() returns it too |
retryable | Whether trying again later can succeed |
retryAfterMs | From Retry-After, when sent |
errors | For validation_failed: up to 10 { path, message }, such as events.0.name |
import { Mira } from '@mirafive/sdk-server'
const mira = new Mira({
key: process.env.MIRAFIVE_SECRET_KEY,
onError: (error) => logger.warn({ code: error.code, status: error.status }, error.message),
})Without onError, Node.js writes [mirafive] … warnings to the console. PHP uses onError, then a PSR-3 logger, then error_log(); Laravel and Symfony log to your app's logger. Input the server would refuse fails early: Node.js reports invalid_event, PHP throws an InvalidArgumentException. The codes and their fixes are listed in Verify and debug and Errors.
A 202 answer is final, even when it kept nothing: dropped with a reason of bot, install_check, ingestion_paused or allowance_exhausted. Do not retry it.