# `POST /v1/events`

A general-purpose event tracker. Use it for anything you want analytics on that **isn't** a signup:

* Page views
* Lead form submissions
* Click-tracking on landing pages
* Custom merchant-defined events

If your data has a meaningful **user identity** (an email address), use `/v1/signups` instead: it counts each email address once per store. Phone numbers are not deduplicated there. `/v1/events` is for higher-volume, identity-less data.

## Auth

Public key + HMAC. Same scheme as [`/v1/signups`](https://dzbuild.com/api-docs/public-key/signups.md). Your backend signs every call.

Two things that block a brand-new key

* **Pilot gate.** The API is pilot-gated in production. A key that DZBuild hasn't enrolled returns `403 forbidden` `"API is in pilot mode; key not enrolled"` on every call.
* **Activation delay.** A freshly issued public key is not usable the instant it is created. Until it is activated for these endpoints you get `401 unauthorized` `"Invalid or revoked public key"`. Contact support right after creating one to have it activated.

Revoking a public key does not stop it on its own: signed calls keep being accepted until support disables it. Tell support whenever you revoke a public key.

## Body

```
{

  "name":       "lead_submitted",

  "properties": { "plan": "pro", "country": "DZ", "form": "footer" },

  "nonce":      "32-hex-single-use"

}
```

| Field        | Type              | Required | Notes                                                                                                                                                                                                                       |
| ------------ | ----------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`       | string ≤ 64 bytes | ✅       | Event name. `snake_case` recommended. The limit counts bytes, so an Arabic letter counts as two. A missing, empty or longer name is not reported: the call still returns `202` and the event is dropped.                    |
| `properties` | object            |          | JSON-serializable values. Stored as-is.                                                                                                                                                                                     |
| `nonce`      | 32-hex string     | ✅       | Use the same value as the `X-DZ-Nonce` header (a body without `nonce` gets the header value). Single-use, forever. See [Dedup](#dedup): the 1-hour rejection window is only the outer guard; a nonce is never usable twice. |

`POST` requests must also carry an **`Idempotency-Key`** header (≤ 64 characters, charset `[A-Za-z0-9_-:.]`). Without it you get `400 bad_request` and the event is never queued. The 32-hex nonce you already generate is a valid value — reuse it.

`name` is stored with each event, but no API endpoint or dashboard screen lists stored events: what you can read back, with a platform key, is the monthly count in `GET /v1/usage` (`usage.event.total`). Keep names to a small, stable vocabulary anyway, and avoid generating them dynamically (e.g., `viewed_product_42` is bad; use `name="viewed_product"` with `properties.product_id=42`).

## Response 202

```
{

  "data": { "status": "queued", "kind": "event", "store_id": 13 },

  "meta": { "request_id": "...", "api_version": "v1", "edge": true }

}
```

Same flow as signups: accepted in \~30 ms, stored within about 5 s.

## Dedup

Dedup is on the **nonce alone**, per store. There's no email-based dedup.

A duplicate is **dropped entirely**: nothing is stored, no usage counter moves, and nothing in the API tells you it happened. The call still returned `202`, because dedup happens after acceptance. So the duplicate is invisible to you — always generate a fresh nonce.

The nonce is guarded in two stages, with different lifetimes:

* For **1 hour** a reused nonce is rejected outright with `401 unauthorized` `"Nonce reused"`.
* **Forever** after that, a recycled nonce is accepted with `202` and then silently discarded — you get no error, and no event.

## Worked example: tracking page views (Node.js, server-side)

```
import crypto from 'node:crypto';



export async function trackEvent(name, properties = {}) {

  const KEY_ID = process.env.DZ_PUBLIC_KEY;

  const SECRET = process.env.DZ_SIGNING_SECRET;

  const nonce = crypto.randomBytes(16).toString('hex');

  const ts    = Math.floor(Date.now() / 1000).toString();

  const body  = JSON.stringify({ name, properties, nonce });

  const hash  = crypto.createHash('sha256').update(body).digest('hex');

  const sig   = crypto.createHmac('sha256', SECRET).update(`${KEY_ID}\n${nonce}\n${ts}\n${hash}`).digest('hex');



  await fetch('https://api.dzbuild.app/v1/events', {

    method: 'POST',

    headers: {

      'Authorization': `DZ-Public ${KEY_ID}`,

      'X-DZ-Timestamp': ts, 'X-DZ-Nonce': nonce, 'X-DZ-Signature': sig,

      'Idempotency-Key': nonce,          // required on every POST

      'Content-Type': 'application/json',

    },

    body,

  });

}



// Inside Express middleware:

app.use((req, res, next) => {

  trackEvent('page_view', { path: req.path, ua: req.get('user-agent') }).catch(() => {});

  next();

});
```

Notice we **don't await** the call from the page-view middleware — fire-and-forget so the user's request isn't blocked. The API returns in \~30 ms anyway, but this protects against transient network issues.

## What counts toward your quota

Every `/v1/events` call that actually lands increments `usage.event.total` AND `usage.event.billable`. There's no "free reads then billable writes" tier here — events are billable from request 1. Duplicates (same nonce) move neither counter, because they are never stored.

If event volume gets high, **batch on your side**: store events in your own queue, fire one `/v1/events` call per logical event in batches of 1 (the API does not accept batches). Every call counts toward the store's per-minute limit, shared with all of its keys.

## Errors

Same as `/v1/signups`. See [Errors](https://dzbuild.com/api-docs/errors.md). The three that catch people out on this endpoint:

| HTTP | Code / Message                                                          | Cause                                                                             |
| ---- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| 400  | `bad_request` "Idempotency-Key header is required for write requests"   | Header missing                                                                    |
| 400  | `bad_request` "Idempotency-Key must be `<=64` chars, `[A-Za-z0-9_-:.]`" | Too long, or uses characters outside that set (base64 `+`, `/`, `=` are rejected) |
| 403  | `forbidden` "API is in pilot mode; key not enrolled"                    | Key not pilot-enrolled                                                            |

## When NOT to use `/v1/events`

* For **storefront orders** — the merchant's dashboard already records them, and duplicating them here just inflates your event count. Note that storefront orders do **not** fire API v1 `order.created` webhooks; only orders created through `POST /v1/orders` do (see the [Event catalog](https://dzbuild.com/api-docs/webhooks/events.md)).
* For **server-internal events** that don't relate to merchant data (CPU usage, cache hits). Use a real APM.
* For **massive volumes** (>1M events / day). Build your own analytics pipeline and only export aggregates here.
