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
| Meter | Kind | What counts as one |
|---|---|---|
api_calls | count | A 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_delivered | count | A webhook delivery that succeeded, counted once however many attempts it took. Failed deliveries are not counted. |
export_rows | count | A product written by a bulk export, counted when the export succeeds. A failed export counts nothing. |
products_tracked | level | The products in your catalogue that are active or probably discontinued, measured every 15 minutes. |
source_runs | count | A run of one of your sources, whatever started it. |
ai_tokens | count | The tokens the AI match judge reads and writes for your catalogue. |
mcp_calls | count | Reserved 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_trackedis 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" }
]
}
| Field | Meaning |
|---|---|
month | The month, YYYY-MM. Pass ?month=2026-09 to read an earlier one; the current month is the default. |
livemode | The mode the figures are for: the request's. |
meters[].kind | count or level, as above. |
meters[].used | The month's figure so far. API calls can lag by a few seconds. |
meters[].quota | The month's cap, or null when the meter is uncapped. Always null in test mode. |
meters[].status | ok; 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.
// 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 }
]
}
| Parameter | Meaning |
|---|---|
meter | Required: one of the meters above. |
granularity | day (the default) or hour. |
from | Start, inclusive, rounded down to the period. |
to | End, 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.
// 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 onlyThreshold 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: 100the 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.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.
| Meter | What a spent quota refuses |
|---|---|
api_calls | Every 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_runs | The 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_rows | Starting an export (POST /v1/exports). The quota is checked when an export starts: one already running finishes. |
ai_tokens | The 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:
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=25rather 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/productsagain. - 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.
Rate limits
Budgets by rate class and tier, the IETF RateLimit headers that state them, what a 429 and a 503 overloaded mean, and how to back off.
Request logs
Every API request your organisation made in the last 31 days, with its answer and the credential behind it. Find a failing request by its ID, filter by status, method, caller, path and time, in the dashboard or the API.