Your backend reports one usage event for each piece of metered work, after the work is done. The Server SDK buffers the events and sends them to your application's region in batches; the region counts each one against the allowance of the buyer organization that consumed it.
Rule, do the work, then report
Ask for a write ruling before the work, and report what was consumed once it has run:
import { randomUUID } from 'node:crypto';
export async function exportReport(claims: TokenClaims, reportId: string) {
const ruling = shiprev.adjudicate({
organization_id: claims.organization_id, // from the verified session token only
capability_key: 'report_exports',
quantity: 1,
operation: 'write',
});
if (!ruling.allowed) return { status: 403, reason: ruling.reason };
const file = await renderExport(reportId); // the metered work
await shiprev.reportUsage({
usage_event_id: randomUUID(),
organization_id: claims.organization_id,
capability_key: 'report_exports',
quantity: 1,
occurred_at: new Date().toISOString(),
});
return { status: 200, file };
}Report only work that ran. If the work fails, report nothing; nothing has been taken from the allowance. When the cost is known only once the work is done, such as a model call billed by the tokens it used, reserve an amount first instead of reporting afterwards.
What an event must hold
| Field | What to put in it |
|---|---|
usage_event_id | Your unique ID for this event, at most 64 characters; see Choose the event ID |
organization_id | The buyer organization from the verified session token, never from a header, parameter or body field the caller controls |
capability_key | A capability the organization's plan grants. Usage of a capability the plan does not grant is refused, not recorded |
quantity | A number greater than 0. Quantities are kept to six decimal places |
occurred_at | When the work happened, as an ISO 8601 timestamp |
properties | Optional: an object stored with the event |
Choose the event ID
The region keeps every usage_event_id it has accepted and drops any later event with the same ID, so an event sent twice is counted once. Two rules follow:
- Give each piece of work its own ID. An ID used again for different work is dropped as a repeat, and that work is never counted.
- Keep the ID with the work if you might retry. If your job can run again after a crash, create the ID once, store it with the job, and report with the same ID on every attempt. The retry is then counted once, whatever happened to the first report.
A random UUID per piece of work meets both rules. IDs that start with a prefix ShipRev reserves for its own use are refused with reserved_identifier.
How events reach the region
reportUsage() adds the event to an in-memory buffer and returns. The buffer is sent:
- as soon as it holds
buffer.batch_sizeevents, 100 by default; - every
buffer.flush_interval_ms, 5 seconds by default; - when you call
flush(); - when you call
stop().
The region handles each event of a batch on its own, so one bad event does not hold up the others:
| Outcome | What happens |
|---|---|
| Accepted | Counted against the organization's allowance |
| Repeat of an accepted ID | Dropped; the event is counted once |
| Refused | Passed to your on_rejected callback and dropped from the buffer; it is not sent again |
| Batch not delivered | The request failed or the region was unreachable: on_flush_failure is called with the events and the error, and the events move to the backlog |
When rulings see the usage
The local mirror changes once the region has counted the event: the region then announces the change, and the client refreshes that organization. A ruling made while the event is still in the buffer does not include it. If the next ruling must see what was just reported, await shiprev.flush() first; if many requests can race for the last of an allowance, reserve the amount before the work instead.
While the region is unreachable
Reporting keeps working. Events that cannot be delivered wait in a backlog, and the first send back online delivers the backlog, oldest first, before any new event. Where the backlog lives is set with backlog.mode:
memory, the default, keeps it in your process. It is lost if the process restarts before the region is reachable again.journalappends it to a local file,backlog.journal_path, which is replayed on start. Use it when your process has a writable disk that survives a restart.host_adapterstores it in your own store, through aBacklogAdapteryou implement. Use it when you run several replicas or have no durable disk.
An event delivered twice, for example because the process stopped between delivery and removal from the backlog, carries the same usage_event_id and is counted once.
Handle refused events
A refused event points at a bug in the code that builds it, so log it and fix the code rather than sending it again:
const shiprev = createClient({
base_url: process.env.SHIPREV_BASE_URL!,
api_key: process.env.SHIPREV_API_KEY!,
on_rejected: (rejected) => {
for (const { usage_event_id, reason } of rejected) logger.error({ usage_event_id, reason }, 'usage refused');
},
});The two reasons you are most likely to meet:
ORGANIZATION_OR_RESOURCE_NOT_FOUND: the organization's plan does not grant the capability, or the key is misspelled. Check the capability key, and rule before the work so that ungranted work does not run.FOREIGN_KEY_VIOLATION_ORGANIZATION_OR_PROJECT_NOT_FOUND: the organization is not a customer of your application, usually because the ID did not come from a session token of this application.
Every reason is listed in Refused usage events.
Stop the client on shutdown
stop() sends what is in the buffer before it resolves. Call it when your process is told to stop, so that the last few seconds of usage are not left behind:
process.on('SIGTERM', async () => {
await shiprev.stop(); // sends the buffer; undeliverable events go to the backlog
process.exit(0);
});ShipRevModule does not stop the client on its own. With NestJS, call stop() on the client you get from InjectShipRevClient() in a provider's onApplicationShutdown(), and turn on shutdown hooks with app.enableShutdownHooks().