> ## Documentation Index
> Fetch the complete documentation index at: https://help.the-meridian.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Usage tracking

An event is an action your app reports back to Meridian. You define it once under [Usage](/introduction-6), your app reports the raw action, and Meridian records it per store, for every app, hosted or not, billing or not.

Charging for it is optional. If you do want a usage charge, give the event terms on a plan (included quantity, overage rate, capped amount) and Meridian does the arithmetic and raises the Shopify usage charges.

<Note>
  Tracking works wherever your app runs. Only the **overage charge** that may follow needs your app [hosted on Meridian](/sdk-hosting). Without that the usage row is still recorded and `track` still succeeds. The charge is simply never raised.
</Note>

```ts theme={null}
const { track } = useMeridian()
await track({ eventKey: "api_calls", quantity: 1 })
```

The same call exists on the server client, for work that happens outside a render:

```ts theme={null}
await client.track({ eventKey: "emails_sent", quantity: 1 })
```

## What to send

| Field | Notes |
| - | - |
| `eventKey` | The event's key from your [Usage](/introduction-6) catalog. Required |
| `quantity` | How many whole units of the event occurred. Defaults to `1`; a whole number, zero or greater. **Not an amount**, see below |
| `properties` | Arbitrary object stored with the event, as sent. See below |
| `idempotencyKey` | Your own de-duplication key. See below |
| `timestamp` | When the event happened, if not now. Needs an `idempotencyKey`. See [Dated events](#dated-events) |

Report the action, not the money. The shop's plan decides what is included and what is billed, so one `track` call bills differently on different plans, and on a plan that doesn't meter the event at all, it just records.

### `quantity` counts, `properties` measure

`quantity` is a **batching multiplier**: how many whole units of the event this one call reports. One order is `quantity: 1`. One call reporting fifty sent emails is `quantity: 50`. It is a whole number.

Everything measured (an amount, a weight, a byte count, a duration) goes in `properties` as a numeric property:

```ts theme={null}
// One order worth $129.50. Not `quantity: 129.5`.
await track({
  eventKey: "order_created",
  quantity: 1,
  properties: { orderId: order.id, total_price: 129.5, weight_kg: 2.4 },
})
```

The distinction is what makes both questions answerable later. "How many orders this period" is the quantity; "how much were they worth" is a [view](/usage-views) totalling `total_price`, and a plan can price either. An amount squeezed into `quantity` can never be separated back out.

A key that isn't in your catalog is accepted and stored anyway, so `track` never fails on a metric you haven't defined yet. It shows up in [Activity](/usage-activity) flagged as not-in-catalog, with a one-click way to declare it.

The key is the only identifier `track` takes. An event's uuid is not an alias for it: a call that sends one is refused with a `422` (`VALIDATION_ERROR`), so the mistake surfaces in your logs instead of as a metric nobody declared.

## Send real properties

`properties` is stored verbatim and never interpreted, which makes it the one part of a `track` call you cannot backfill. Send the ids and the numbers you might want to aggregate later:

```ts theme={null}
await client.track({
  eventKey: "orders_synced",
  quantity: 1,
  properties: {
    orderId: order.id,          // an id you can count distinctly
    amount: order.totalPrice,   // a number you can sum
    channel: order.sourceName,  // a dimension you can group by
  },
})
```

The rule of thumb: **object ids and numeric values**. A quantity tells you how many times something happened; the properties tell you what it was worth. Aggregations over stored properties are computed from history, so a property you sent from day one is available retroactively. One you never sent is gone for good.

## Idempotency

Pass an `idempotencyKey` when a retry could double-count: a webhook handler, a queued job, anything that might run twice. Meridian keys it per app and shop, so a second call with the same key returns the original event and charges nothing.

The key also changes how the SDK behaves on a flaky network: a `track` call **with** an idempotency key is retried on `429` and `5xx`, and one without it is sent exactly once. Where accuracy matters, send a key.

## Sending events in batch

Anything that runs on a schedule produces its events in a burst: an hourly order sync, a nightly reconciliation, a queue worker draining a backlog. Send them as one batch with `trackMany` rather than one `track` call per event.

```ts theme={null}
const client = createMeridianServerClient(shopToken)

await client.trackMany(
  orders.map((order) => ({
    eventKey: "order_created",
    quantity: 1,
    properties: { total_price: order.totalPrice, order_number: order.name },
    idempotencyKey: `order:${order.id}`,
  })),
)
```

The same call exists on [`createMeridianApp`](/sdk-app), keyed by shop domain rather than token. It is best-effort like the rest of that surface: `null` when Meridian is unreachable, never an exception in your job.

```ts theme={null}
await meridian.trackMany({ shop, events })
```

For a single event, the same best-effort facade exposes `track`:

```ts theme={null}
await meridian.track({ shop, eventKey: "order_created", idempotencyKey: "order:1001" })
```

It returns the recorded event, or `null` when unconfigured, identify fails, tracking fails, or the rate-limit breaker is open. See [Tracking usage events](/sdk-app#tracking-usage-events) for the failure and retry contract.

A batch beats a `Promise.all` of `track` calls for three reasons. It is one round trip instead of N. It counts as **one** request against your [rate limit](/sdk-errors#rate-limits) instead of N. And Meridian records it in a single database transaction, so the events cannot deadlock against each other, which is exactly what N near-simultaneous writes for the same shop can do.

### What a batch does

Every event in the batch lands in one transaction. Either all of them are recorded or none is, and the counters and views that read them move once, by the sum. Automations still see each event: `usage_event.tracked` fires once per event recorded, and a [threshold trigger](/usage-view-threshold-crossed) is evaluated once for the batch as a whole, on where the store stood before it and after it. Events [dated](#dated-events) more than 24 hours back are the exception: they fire neither. One transition carries one event, and a crossing names the exact one: the event of the batch at which the store's running total reached that automation's threshold.

Idempotency is per event. An event whose key was already recorded is answered with the stored event and `created: false`, contributes nothing, and does not stop the others. A retry of the whole batch is therefore safe when every event carries a key, and that is also the rule the SDK follows: `trackMany` is retried on `429` and `5xx` only when every event has an `idempotencyKey`, and sent exactly once otherwise.

Validation is all or nothing on shape. An event uuid in place of a key anywhere in the batch, or two events sharing an idempotency key, is a `422` for the whole request and nothing is recorded. An undeclared key is still accepted and counted, as it is for a single event.

A batch holds at most **50** events. That cap is the trade-off for counting as one request: a bigger batch would hold the store's counters locked for longer than a request should.

### The HTTP shape

`trackMany` posts to the same endpoint as `track`, with the events wrapped in an array:

```bash theme={null}
curl -X POST https://api.the-meridian.ai/api/public/events \
  -H "Authorization: Bearer $SHOP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      { "eventKey": "order_created", "properties": { "total_price": 41.28 }, "idempotencyKey": "order:1001" },
      { "eventKey": "order_created", "properties": { "total_price": 12.00 }, "idempotencyKey": "order:1002" }
    ]
  }'
```

The response lists one result per event, in the order you sent them:

```json theme={null}
{
  "success": true,
  "data": {
    "events": [
      { "id": "9021", "eventKey": "order_created", "quantity": 1, "timestamp": "2026-09-14T09:00:12.000000Z", "created": true },
      { "id": "8877", "eventKey": "order_created", "quantity": 1, "timestamp": "2026-09-13T08:00:03.000000Z", "created": false }
    ]
  },
  "meta": { "received": 2, "created": 1 },
  "error": null
}
```

The second event above was a replay: its `id` and `timestamp` are the stored event's, not this request's.

## Dated events

An event is dated the moment Meridian receives it. When your app reports usage late, pass a `timestamp` with the moment it actually happened, so the event lands on the right day, in the right charts and in the right billing period. Two cases call for it:

* **A sync that runs behind.** An hourly order sync that was down for a morning catches up with orders placed hours ago.
* **A replay after an incident.** Your queue or your logs hold the events a failed deploy never reported, and you send them now.

```ts theme={null}
await client.trackMany(
  missedOrders.map((order) => ({
    eventKey: "order_created",
    properties: { total_price: order.totalPrice },
    idempotencyKey: `order:${order.id}`,
    timestamp: new Date(order.createdAt),
  })),
)
```

`track` takes a `timestamp` too, on the [server client](/sdk-server-client) and on [`createMeridianApp`](/sdk-app). The [`useMeridian()`](/sdk-use-meridian) hook does not: backdating is a job for your server, not for the browser.

### The rules

| Rule | Detail |
| - | - |
| **Format** | RFC 3339 with an offset: `2026-09-26T14:05:00Z` or `2026-09-26T16:05:00+02:00`. The SDK also takes a `Date` and sends it as its ISO string. Fractions of a second are accepted and dropped |
| **At most 5 minutes ahead** | Room for a server clock that runs fast. An event dated inside those 5 minutes is stored at the moment Meridian received it, so no event is ever dated in the future |
| **At most 5 years back** | Counted from the moment Meridian receives the event |
| **An `idempotencyKey` is required** | A backfill is the kind of script that gets re-run. Without keys, a second run would record the history twice, and recorded events cannot be deleted through the API |
| **Installed stores only** | The call is authenticated by the store's shop token, which stops working when the store uninstalls your app. You cannot send events, dated or not, for a store that has uninstalled |

### Billing follows the event's own period

A dated event is counted and billed according to the period it happened in, never the one it arrived in. The period is the one described in [Which period](#which-period): the store's 30-day billing cycle when it is subscribed, the UTC calendar month otherwise.

* **Dated inside the store's current period**: it counts exactly like a live event. It moves `usedThisPeriod` and the plan's counters, and it can raise an overage charge, however many days ago it happened.
* **Dated before the current period started**: it is history. It is stored, and it shows in [Activity](/usage-activity), in the charts and in every [view](/usage-views) whose window includes its date (an all-time view always does). It moves no counter and is never charged, so replaying last year's orders never bills a merchant today.

<Note>
  An event dated just before a period closed and received just after it is never billed. Take an order placed at 09:50 in a period that rolled over at 10:00, reported at 10:20: its period is closed, so it is recorded but counted in neither period. It only costs you anything if the store had already gone past its included quantity in the period that closed.
</Note>

### History from before Meridian

Moving an existing app to Meridian usually means bringing its past with it: months or years of orders that happened before the app existed here. That history is free of your app's own Meridian allowances during the app's first 30 days, so a migration never spends the month and leaves your live events refused.

An event uses neither the app's monthly **usage events** nor its **SDK requests** when all of this holds:

1. It is sent with `trackMany`.
2. Its `timestamp` is before the moment the app was created on Meridian.
3. Meridian receives it at most 30 days after the app was created.

Everything else counts as usual: an event sent with `track`, an event dated after the app was created, and anything sent once the 30 days are over. A `trackMany` call made only of such events costs no SDK request. A call that mixes them with other events costs one, and only its other events use the usage events allowance.

While the window is open, the [Events](/usage) page and the SDK step of the setup guide show the date it closes.

```ts theme={null}
// A one-off migration script, run in the app's first 30 days on Meridian.
for (const batch of chunk(ordersPlacedBeforeMeridian, 50)) {
  await client.trackMany(
    batch.map((order) => ({
      eventKey: "order_created",
      properties: { total_price: order.totalPrice },
      idempotencyKey: `order:${order.id}`,
      timestamp: new Date(order.createdAt),
    })),
  )
}
```

Send the history in batches of up to 50 events per call. The [rules above](#the-rules) still apply: an `idempotencyKey` on every event (so the script can be re-run safely), at most 5 years back, and installed stores only. Merchant billing is not affected: an old event is counted for the store exactly as [Billing follows the event's own period](#billing-follows-the-events-own-period) describes, so one dated inside the store's current period still counts towards its usage.

### Automations fire for recent events only

Only an event dated less than **24 hours** before Meridian receives it fires automations: [Custom event is tracked](/usage-event-tracked) and [Usage view crosses a threshold](/usage-view-threshold-crossed). An older one fires nothing, even when it falls in the current period and is counted. It simply becomes part of where the store stood when the next live event arrives, so a replay sends no late "first order" email and no burst of "new order" emails, and the next live event is measured correctly.

The cost of that rule: a milestone reached only by events that arrive more than 24 hours late is never sent.

A threshold also only counts the events inside the view's current window. An order dated last month does not move a month-to-date view, so it cannot take one across a threshold, even when it arrives today. See [When it fires](/usage-view-threshold-crossed#when-it-fires).

### A known key keeps its first date

A dated event is idempotent like any other. Sending an `idempotencyKey` Meridian has already recorded, with a different `timestamp`, changes nothing: you get the stored event back (with `created: false` in a batch), carrying the `timestamp` it was stored with. The first write wins, and the response tells you which date was kept. Meridian never moves an event it has recorded.

### Errors

A timestamp that breaks a rule is refused with a `422` and the code `VALIDATION_ERROR`, and nothing in the request is recorded. `error.details.fields` names the field. In a batch the name carries the event's position (`events.3.timestamp`), and one bad event refuses the whole batch.

| You sent | Field | Message |
| - | - | - |
| A string without an offset, a date alone, a time without seconds, or anything else that is not RFC 3339 | `timestamp` | The timestamp must be an RFC 3339 date-time with an offset |
| A number (epoch seconds or milliseconds) or `null` | `timestamp` | The timestamp field must be a string |
| A moment more than 5 minutes ahead | `timestamp` | The timestamp may be at most 5 minutes in the future |
| A moment more than 5 years back | `timestamp` | The timestamp may be at most 5 years in the past |
| A `timestamp` with no `idempotencyKey` | `idempotencyKey` | An idempotencyKey is required when the event carries a timestamp |

```json theme={null}
{
  "success": false,
  "data": null,
  "meta": {},
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": {
      "fields": {
        "events.3.idempotencyKey": ["An idempotencyKey is required when the event carries a timestamp."]
      }
    }
  }
}
```

The SDK catches one mistake before the request: a `Date` that is invalid (`new Date("garbage")`) makes the server client's `track` and `trackMany` throw a `TypeError` without calling Meridian, and makes the `createMeridianApp` methods resolve `null`. Sent as JSON, an invalid `Date` would have become `null`.

## Reading usage back

```ts theme={null}
const usage = getUsage("api_calls")
usage?.usedThisPeriod      // consumed so far this period
usage?.includedQuantity    // what the plan includes, if it meters this event
usage?.cappedAmount        // the merchant-approved spending cap
```

`getUsage` answers for **every event in your catalog**, whether or not the shop's plan prices it: an app that meters without selling usage reads its own numbers, and so does a store on no plan at all. `includedQuantity` and `cappedAmount` appear only where the plan actually sets terms; their absence is how you tell the metric isn't billed on this plan.

Usage arrives with the rest of the provider's state, so showing a merchant their remaining allowance costs nothing extra. Call `refresh()` after a burst of tracking if you need the figure updated in the same session. The snapshot is read at load, not per call.

### Which period

`usedThisPeriod` is a **current-period** figure. It resets to zero at each period boundary, and the merchant's included quantity refreshes with it.

| The store | The period |
| - | - |
| Subscribed to one of your plans | Its Shopify billing cycle: a fixed 30 days from when the subscription activated |
| On no subscription | The UTC calendar month |

`entitlements.periodStart` and `entitlements.periodEnd` give you that window, so you can tell a merchant when their allowance refreshes rather than letting them discover it. `periodEnd` is exclusive: it is the instant the counter resets.

Overage is charged against the same window. A store that ran over its included quantity last cycle starts the next one with a full allowance again.

The [account page](/account) already renders this as a bar per event, including the over-limit state and the raise-cap flow, so you do not have to.

On your side, **Usage → Activity** and **Usage → By store** show the same events as a raw log and a per-store rollup. See [Activity and usage by store](/usage-activity).

## Gating on an allowance

`track` records usage; it does not refuse. Deciding whether an action is allowed at all is yours:

```ts theme={null}
const usage = getUsage("api_calls")
const remaining = (usage?.includedQuantity ?? 0) - (usage?.usedThisPeriod ?? 0)

if (remaining <= 0 && !isEnabled("unlimited_api")) return <LimitReached />
```

For a plan that bills overage, going over is a charge rather than a wall: Shopify's capped amount is the ceiling, and the merchant raises it. For a plan with no overage rate, the included quantity is the limit and your app enforces it.

## What a tracked event triggers

The usage row is written before anything else happens, so it survives a failed charge. A billing problem never loses the record of what your app did. If Shopify's answer to an overage charge is lost, Meridian sends that same charge again, under the same idempotency key, before it bills anything new, so a retry never charges the same usage twice.

Tracking also fires the `usage_event.tracked` [automation trigger](/usage-event-tracked), so an event can drive an email or a CRM update without further wiring. A [dated event](#dated-events) more than 24 hours old is recorded and fires nothing. The `properties` you sent travel with it: each scalar one becomes a `properties.<name>` [variable](/automation-variables#event-properties) the flow can branch on and an email can print, under the exact key you used. A failed dispatch is logged and never fails the `track` call. Usage is also one of the datasets the [report builder](/report-builder) can read.

A call de-duplicated by its idempotency key fires nothing and charges nothing. Only the first one does.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.