FurniOpsDocs

Usage and quotas

What FurniOps meters, how a month is counted, reading usage with GET /v1/usage, the 80% and 100% threshold events, and what a spent quota refuses.

FurniOps meters what your organisation uses, per mode and per hour. Your plan can cap a meter with a monthly quota. You can read where every meter stands at any time, and FurniOps tells your webhook endpoints before a quota runs out.

A quota is not a rate limit

Rate limits smooth traffic over seconds and refill on their own. A quota caps a whole month: once it is spent, the metered action is refused until the month ends.

The meters

MeterKindWhat counts as one
api_callscountA request made with your key and let through by the rate limiter. A request the rate limiter or a spent quota refuses is not counted.
webhooks_deliveredcountA webhook delivery that succeeded, counted once however many attempts it took. Failed deliveries are not counted.
export_rowscountA product written by a bulk export, counted when the export succeeds. A failed export counts nothing.
products_trackedlevelThe products in your catalogue that are active or probably discontinued, measured every 15 minutes.
source_runscountA run of one of your sources, whatever started it.
ai_tokenscountThe tokens the AI match judge reads and writes for your catalogue.
mcp_callscountReserved for the MCP server, which is not available yet. It stays at 0.

Counts and levels

  • A count adds up over the month: 30 runs on the 1st and 20 on the 2nd make 50.
  • A level is measured, not added up. The month's figure is its highest reading, so products_tracked is the most products you tracked at any point in the month.

Months

A month is a calendar month in UTC: October runs from 2026-10-01T00:00:00Z up to, but not including, 2026-11-01T00:00:00Z. Usage is recorded per hour, and quotas renew when the month changes.

Live and test mode are metered separately. A request reads the usage of the mode it acts in: a key's own, or the one an access token names with FurniOps-Mode.

Reading your usage

GET /v1/usage answers every meter's month so far, in a fixed order, with its quota:

curl https://api.furniops.com/v1/usage \
  -H "Authorization: Bearer $FURNIOPS_API_KEY"
{
  "object": "usage",
  "month": "2026-10",
  "livemode": true,
  "meters": [
    { "meter": "api_calls", "kind": "count", "used": 412870, "quota": 500000, "status": "warning" },
    {
      "meter": "webhooks_delivered",
      "kind": "count",
      "used": 18342,
      "quota": null,
      "status": "ok"
    },
    { "meter": "export_rows", "kind": "count", "used": 0, "quota": 250000, "status": "ok" },
    { "meter": "products_tracked", "kind": "level", "used": 8214, "quota": 25000, "status": "ok" },
    { "meter": "source_runs", "kind": "count", "used": 96, "quota": 3000, "status": "ok" },
    { "meter": "ai_tokens", "kind": "count", "used": 0, "quota": null, "status": "ok" },
    { "meter": "mcp_calls", "kind": "count", "used": 0, "quota": null, "status": "ok" }
  ]
}
FieldMeaning
monthThe month, YYYY-MM. Pass ?month=2026-09 to read an earlier one; the current month is the default.
livemodeThe mode the figures are for: the request's.
meters[].kindcount or level, as above.
meters[].usedThe month's figure so far. API calls can lag by a few seconds.
meters[].quotaThe month's cap, or null when the meter is uncapped. Always null in test mode.
meters[].statusok; warning from 80% of the quota; exceeded from 100%, when the action is refused.

The key needs the usage:read scope. Reading your usage is never refused for a spent quota, so you can always see why something was.

usage.ts
// This month so far, in the key's mode; pass { month: "2026-09" } for an earlier one.
const usage = await furniops.usage.get();
for (const { meter, kind, used, quota, status } of usage.meters) {
  if (quota === null) continue; // uncapped: always so in test mode
  console.log(`${meter} (${kind}): ${used} of ${quota}, ${status}`); // ok | warning | exceeded
}

Usage over time

GET /v1/usage/series answers one meter period by period, for a chart or your own reporting:

curl "https://api.furniops.com/v1/usage/series?meter=api_calls&granularity=day&from=2026-10-01T00:00:00Z&to=2026-10-04T00:00:00Z" \
  -H "Authorization: Bearer $FURNIOPS_API_KEY"
{
  "object": "usage_series",
  "meter": "api_calls",
  "granularity": "day",
  "livemode": true,
  "data": [
    { "start": "2026-10-01T00:00:00Z", "quantity": 131204 },
    { "start": "2026-10-02T00:00:00Z", "quantity": 140877 },
    { "start": "2026-10-03T00:00:00Z", "quantity": 140789 }
  ]
}
ParameterMeaning
meterRequired: one of the meters above.
granularityday (the default) or hour.
fromStart, inclusive, rounded down to the period.
toEnd, exclusive.

A series covers at most 7 days by hour and 400 days by day; a longer range answers 400 invalid_request. Periods without usage are left out. A level meter's point is its highest reading in the period.

usage.ts
// API calls by hour over one day: up to 7 days by hour, 400 by day. `to` is exclusive.
const series = await furniops.usage.getSeries({
  meter: "api_calls",
  granularity: "hour",
  from: "2026-10-03T00:00:00Z",
  to: "2026-10-04T00:00:00Z",
});
for (const point of series.data) console.log(point.start, point.quantity); // hours with usage only

Threshold events

When a live meter reaches 80% of its quota, and again at 100%, FurniOps sends a usage.threshold_reached event:

{
  "id": "evt_034XqBaL2mRLTZPXyRbaUo",
  "type": "usage.threshold_reached",
  "spec_version": "1.0",
  "source": "furniops/backend",
  "subject": "org_034XqBaL2mRLTZPXyRbaUj",
  "time": "2026-10-04T09:12:00Z",
  "tenant_id": "org_034XqBaL2mRLTZPXyRbaUj",
  "version": 59703504,
  "schema_version": 1,
  "correlation_id": null,
  "causation_id": null,
  "livemode": true,
  "data": {
    "meter": "api_calls",
    "threshold": 80,
    "used": 400112,
    "quota": 500000,
    "month": "2026-10"
  }
}
  • It is sent once a month for each meter and threshold, within about a minute of the usage that reached it.
  • At threshold: 100 the metered action is refused from then until the month ends.
  • When a quota is raised or removed, a threshold the usage no longer reaches is withdrawn: a spent quota's refusals lift within a minute, and the event is sent again if the new quota is reached later in the month.
  • Test mode has no quotas, so it never sends one.

The event belongs to your organisation, not to a catalogue: the change feed and the stream do not carry it. Only webhooks deliver it, to live endpoints subscribed to usage.threshold_reached (or to *). See Organisation events.

usage.ts
// `usage.threshold_reached` reaches only live webhook endpoints subscribed to it (or to `*`).
// `WebhookEvent` covers it: narrowing on `type` gives its data.
const event: WebhookEvent = await furniops.webhooks.verify(raw, headers, secrets);
if (event.type === "usage.threshold_reached") {
  const { meter, threshold, used, quota, month } = event.data;
  await alerts.send(
    threshold === 100
      ? `${meter} quota spent for ${month} (${used}/${quota}): refused until the month ends`
      : `${meter} at ${threshold}% of its ${month} quota (${used}/${quota})`,
  );
}

When a quota is spent

A spent quota refuses the action it meters, in live mode only, with 429 quota_exceeded:

{
  "type": "https://docs.furniops.com/errors/quota_exceeded",
  "title": "Monthly quota used up",
  "status": 429,
  "code": "quota_exceeded",
  "detail": "This month's API calls quota is spent in live mode; it renews when the month ends (UTC). Test mode is never capped.",
  "request_id": "req_034XqBaL2mRLTZPXyRbaUq",
  "doc_url": "https://docs.furniops.com/errors/quota_exceeded"
}

Retry-After gives the seconds until the month ends. Nothing was done, so the request is safe to send again then.

MeterWhat a spent quota refuses
api_callsEvery API request, except reading your usage. The cap takes effect within a minute of the call that spends it, so a few more calls can be served.
source_runsThe runs you start: POST /v1/sources/{id}/runs, POST /v1/sources/{id}/replay and completing an upload (POST /v1/uploads/{id}/complete). Scheduled runs wait for the new month. Runs that follow a change you made, such as confirming a mapping or saving category rules, still run, so a change never half-applies.
export_rowsStarting an export (POST /v1/exports). The quota is checked when an export starts: one already running finishes.
ai_tokensThe AI match judge pauses until the new month. Its pairs stay open for review.

Two meters are never refused, because refusing them would lose your data: webhooks keep being delivered, and sources keep updating your catalogue however many products it tracks.

Handling quota_exceeded

A spent quota is a 429 like a rate limit, but it lasts until the month ends, so do not retry it in a loop. Tell the two apart by code. The SDK throws RateLimitError at once, because the Retry-After is longer than the 60 seconds it waits inside a call:

usage.ts
try {
  await furniops.sources.createRun("src_034XqBaL2mRLTZPXyRbaUc", {});
} catch (error) {
  // A spent quota is a 429 like a rate limit, but it lasts until the month ends: the SDK does not
  // wait for it. Tell the two apart by code.
  if (error instanceof RateLimitError && error.code === "quota_exceeded") {
    await jobs.add("run", {}, { delay: error.retryAfterMs ?? 3_600_000 }); // the new month (UTC)
  } else throw error;
}

Then read GET /v1/usage to see which meter is spent. Your test keys are never capped, so development can carry on in test mode.

Using less

  • Follow the change feed with wait=25 rather than polling objects: one long-poll replaces many reads.
  • Take a whole catalogue as a bulk export when a plan includes it, rather than paging GET /v1/products again.
  • Subscribe webhook endpoints to the event types you handle, not to *: every successful delivery counts.
  • Develop and test with test keys: test usage never counts against a quota.

On this page