# Server-side events

Source: https://docs.mirafive.io/guides/server-side

> 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](https://docs.mirafive.io/sdks/browser). For the request format, see [Send events](https://docs.mirafive.io/ingest-api/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](#idempotency).
- **There is no browser**: APIs, CLIs, background workers.

## Secret key

A server source has one kind of key, the **secret key** (`mf_…`). See [Keys](https://docs.mirafive.io/keys) for where to create it. Put it in `MIRAFIVE_SECRET_KEY`:

```sh title=".env"
MIRAFIVE_SECRET_KEY=mf_…
```

Rules:

- The secret key travels only in the `Authorization: Bearer` header. 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-server` throws when created where `window` and `document` exist, and the browser SDK refuses a `secretKey` option.
- A secret key that arrives with an `Origin` or `Sec-Fetch-Site` header, 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 with `403 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-server` accepts only `https://` hosts, and `http://` for `localhost`, `127.0.0.1` and `[::1]`, since the key is in a header.

## Set up

Create one client per process and reuse it:

**Node.js**

```ts title="src/mira.ts"
import { Mira } from '@mirafive/sdk-server'

export const mira = new Mira({ key: process.env.MIRAFIVE_SECRET_KEY })
```

```ts
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](https://docs.mirafive.io/sdks/node).

**PHP**

```php
use MiraFive\Mira;

$mira = new Mira(); // reads MIRAFIVE_SECRET_KEY and MIRAFIVE_HOST

$mira->track('signup', userId: (string) $user->id, properties: ['plan' => 'pro']);
```

See [PHP](https://docs.mirafive.io/sdks/php).

**Laravel**

```php
use MiraFive\Laravel\Facades\Mira;

Mira::track('signup', userId: (string) $user->id, properties: ['plan' => 'pro']);
```

The package reads `MIRAFIVE_SECRET_KEY` from `.env`. See [Laravel](https://docs.mirafive.io/sdks/laravel) and [Symfony](https://docs.mirafive.io/sdks/symfony).

## Mode

Server clients default to [full mode](https://docs.mirafive.io/guides/consent#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:

```ts
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:

**Cloudflare Workers**

```ts title="src/index.ts"
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')
  },
}
```

**Vercel**

```ts
import { Mira } from '@mirafive/sdk-server'
import { waitUntil } from '@vercel/functions'

export const mira = new Mira({ key: process.env.MIRAFIVE_SECRET_KEY, waitUntil })
```

Every delivery, including the one the flush timer starts, is handed to `waitUntil`.

**Next.js**

```ts title="app/api/checkout/route.ts"
import { mira } from '@mirafive/sdk-next/server'

export async function POST(request: Request): Promise<Response> {
  const order = await request.json()

  mira().track('checkout_started', { userId: order.customerId })

  return Response.json({ ok: true })
}
```

`mira()` returns a process-wide client and flushes it with `after()`, once the response is sent.

**Any runtime**

```ts
mira.track('signup', { userId: user.id })
await mira.flush()
```

Await the flush before the function returns.

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](https://docs.mirafive.io/sdks/laravel), `messenger` in [Symfony](https://docs.mirafive.io/sdks/symfony), or the `handOff` option of the [PHP SDK](https://docs.mirafive.io/sdks/php).

## 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:

**Node.js**

```ts
const receipt = await mira.send(
  [{ name: 'order completed', userId: order.customerId, properties: { revenue: 129, currency: 'EUR' } }],
  { idempotencyKey: `order-${order.id}` },
)
```

**PHP**

```php
$receipt = $mira->send(
    [['name' => 'order completed', 'userId' => (string) $order->customer_id, 'properties' => ['revenue' => 129, 'currency' => 'EUR']]],
    idempotencyKey: 'order-'.$order->id,
);
```

**Laravel**

```php
use MiraFive\Laravel\Facades\Mira;

$receipt = Mira::send(
    [['name' => 'order completed', 'userId' => (string) $order->customer_id, 'properties' => ['revenue' => 129, 'currency' => 'EUR']]],
    idempotencyKey: 'order-'.$order->id,
);
```

The SDK derives the batch id from the key, the same way in every language:

```text
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:

```ts
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](https://docs.mirafive.io/guides/identify-users#link-browser-and-server-events) 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` |

```ts
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](https://docs.mirafive.io/guides/verify-and-debug#refusals) and [Errors](https://docs.mirafive.io/ingest-api/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.
