This page lists what @shiprev/server-sdk-node exposes and the values it can return. For a walkthrough, start with Backend and the Server SDK.
Version
The current version is 0.1.0-beta.0, installed with the beta tag (npm install @shiprev/server-sdk-node@beta). It is a beta: the interface may change before 1.0. New versions are listed in the changelog. The Server SDK is published for Node.js only; it needs Node.js 20 or later.
| Import | Contents |
|---|---|
@shiprev/server-sdk-node | createClient(), ShipRevClient, the types, TokenRejectedError, DisconnectedWriteError, and the backlogs MemoryBacklog, JournalBacklog and HostAdapterBacklog |
@shiprev/server-sdk-node/nest | ShipRevModule, ShipRevAuthGuard, ShipRevCapabilityGuard, RequireCapability() and InjectShipRevClient() |
The main entry never loads NestJS. The /nest entry needs @nestjs/common and @nestjs/core (9, 10 or 11) and reflect-metadata.
Options
createClient(options) and new ShipRevClient(options) take the same options. ShipRevModule.forRoot(options) takes them as well, and ShipRevModule.forRootAsync() builds them with a useFactory from other providers; both start the client in the background.
| Option | Default | Meaning |
|---|---|---|
base_url | required | Your application's API address, as the developer console shows it |
api_key | required | Your application's SDK key |
presets.cold_start | 'deny' | The ruling on reads while nothing is loaded and the region is unreachable: 'allow' or 'deny' |
presets.connect_timeout_ms | 5000 | Timeout of every request to the region, in milliseconds |
presets.reservation_ttl_s | set by the region | Default lifetime of a reservation, in seconds |
buffer.batch_size | 100 | Usage events per request; reaching it sends the buffer at once |
buffer.flush_interval_ms | 5000 | How often the buffer is sent in the background; 0 turns the timer off |
backlog.mode | 'memory' | Where undelivered usage waits: 'memory', 'journal' or 'host_adapter' |
backlog.journal_path | .shiprev-usage-journal.log in the working directory | The append-only file of the 'journal' mode |
backlog.adapter | none | Your BacklogAdapter, required by the 'host_adapter' mode |
on_rejected | none | Called with the usage events the region refused; see Refused usage events |
on_flush_failure | none | Called with the events of a batch that could not be delivered and the error; the events go to the backlog |
presets.cold_start
A cold start is a process that has not yet loaded anything from the region and cannot reach it. Writes are then always refused with disconnected_write. Reads follow presets.cold_start: with 'deny', the default, they are refused with cold_start_denied; with 'allow', they are allowed with source client_default. Choose 'allow' only for reads that may be served to anyone while your service is cut off.
const shiprev = createClient({
base_url: process.env.SHIPREV_BASE_URL!,
api_key: process.env.SHIPREV_API_KEY!,
presets: { cold_start: 'allow' },
});Client methods
| Method | What it does |
|---|---|
start() | Fetches your region's signing keys, exchanges the SDK key for a service token, opens the push stream and loads the snapshot. Never throws; resolves to { project_id, snapshot_revision, state } |
stop() | Closes the push stream, stops every timer and sends buffered usage |
verifyToken(token) | Verifies an end user's session token locally; resolves to TokenClaims or throws (see Errors) |
adjudicate(options) | Rules on a capability from the local mirror. Synchronous, no I/O |
adjudicateAsync(options) | The same, but asks the region when online and the mirror does not know the organization or the capability |
guardWrite(options, action) | Runs action only if a 'write' ruling allows it; otherwise throws DisconnectedWriteError |
reportUsage(event) | Adds a usage event to the buffer |
flush() | Sends the backlog, then the buffer, and resolves once each event was accepted, refused or moved to the backlog |
reserve(options) | Reserves an amount before work whose cost is known only afterwards |
confirm(reservationId, actualAmount) | Settles a reservation; without actualAmount, the reserved amount |
release(reservationId) | Gives a reservation back in full |
state() | The client's state; see Client state |
loadSnapshot(), setOnline(), setM2MToken() and setPublicKey() exist for tests and fault injection; do not call them in production code.
adjudicate()
const ruling = shiprev.adjudicate({
organization_id: claims.organization_id, // from the verified token only
capability_key: 'report_exports',
quantity: 1, // default 1; 0 checks that the capability is granted
operation: 'write', // 'read' or 'write', required
});operation is required: a ruling without 'read' or 'write', or with a negative or non-finite quantity, is refused with invalid_request. The organization must come from the verified session token, never from a header, parameter or body field the caller controls.
reportUsage()
await shiprev.reportUsage({
usage_event_id: randomUUID(), // your unique ID; the region drops repeats
organization_id: claims.organization_id,
capability_key: 'report_exports',
quantity: 1, // a finite number greater than 0
occurred_at: new Date().toISOString(),
});reportUsage() throws a RangeError for a quantity that is not a finite number. The event may also carry properties, an object kept with the event.
reserve(), confirm() and release()
For work whose cost is known only once it is done, reserve an amount first, then confirm what was used or release the reservation:
const reservation = await shiprev.reserve({
organization_id: claims.organization_id,
capability_key: 'report_exports',
amount: 10, // what the work may use at most
ttl_seconds: 300, // optional; default presets.reservation_ttl_s, then the region's default
});
try {
const used = await runExport(); // your work
await shiprev.confirm(reservation.reservation_id, used); // settles the amount actually used
} catch (error) {
await shiprev.release(reservation.reservation_id); // gives the whole reservation back
throw error;
}| Method | Resolves to |
|---|---|
reserve(options) | { reservation_id, status, expires_at } |
confirm(reservationId, actualAmount) | { reservation_id, status, confirmed_amount } |
release(reservationId) | { reservation_id, status } |
| Status | Meaning |
|---|---|
PENDING | Reserved and waiting to be confirmed or released |
CONFIRMED | Settled with confirm() |
RELEASED | Given back with release() |
RECLAIMED | Not settled before expires_at, and given back by the region |
confirm() without actualAmount confirms the amount that this client reserved; for a reservation made by another process, pass the amount. A refusal by the region, for example an allowance that cannot cover the amount, throws (see Errors).
Rulings
adjudicate() and adjudicateAsync() return a Ruling:
| Field | Type | Meaning |
|---|---|---|
allowed | boolean | Whether to go ahead |
source | string | Where the ruling came from; see below |
reason | string, optional | Why the call was refused; see below |
remaining | number, optional | What is left of the allowance: the allowance of the current period minus the usage counted so far, never below 0. It does not subtract the quantity of this call |
limit | number, optional | The allowance of the current period |
adjudicated_at | number | Milliseconds since the epoch |
ruling.source
| Source | When |
|---|---|
realtime | Online; ruled from the local mirror |
authoritative | Online; the region ruled, because the mirror did not know the organization or capability (adjudicateAsync() only) |
degraded | The push stream is down; ruled from the last snapshot |
client_default | No basis for a ruling: an invalid request, a cold start, or an organization or capability the mirror does not know |
ruling.reason
| Reason | Source | Meaning | What to do |
|---|---|---|---|
quota_exhausted | realtime, authoritative | The call goes beyond what is left of the allowance, and the capability refuses beyond it | Refuse the call, or offer an upgrade |
capability_not_granted | client_default | Online, but the mirror holds no such capability for the organization. adjudicateAsync() then asks the region | Use adjudicateAsync(), or check the capability key and the customer's plan |
capability_not_found | authoritative | The region confirms the organization's plan does not grant the capability | Refuse the call |
disconnected_write | degraded, client_default | A write while the region is unreachable | Refuse or retry later; writes are never allowed offline |
cold_start_denied | client_default | A read on a cold start, with presets.cold_start set to 'deny' | Retry once start() has reached LIVE |
invalid_request | client_default | operation missing or unknown, or quantity negative or not finite | Fix the call |
remaining and limit
remaining and limit are present on rulings made from a capability the mirror or the region holds, and absent on refusals with disconnected_write, cold_start_denied, invalid_request and capability_not_granted. Every capability a plan grants has a numeric allowance; there is no unlimited allowance, so remaining is always a number when present. Beyond the allowance, a capability whose overage policy is allow and bill is allowed locally with remaining at 0; a capability that blocks, or prompts to upgrade, is refused with quota_exhausted.
Refused usage events
The region checks each usage event it receives. A refused event is not retried: it is passed to on_rejected, with usage_event_id and reason, and dropped from the buffer.
const shiprev = createClient({
base_url: process.env.SHIPREV_BASE_URL!,
api_key: process.env.SHIPREV_API_KEY!,
on_rejected: (rejected) => {
for (const event of rejected) logger.warn({ usage_event_id: event.usage_event_id, reason: event.reason }, 'usage refused');
},
});| Reason | Meaning |
|---|---|
MALFORMED_EVENT_PAYLOAD | The event is not an object |
MISSING_USAGE_EVENT_ID | No usage_event_id; the event is reported with usage_event_id unknown |
reserved_identifier | The usage_event_id starts with a prefix ShipRev reserves for its own use |
MISSING_ORGANIZATION_ID | No organization_id |
MISSING_CAPABILITY_KEY | No capability_key |
MISSING_QUANTITY | No quantity |
INVALID_QUANTITY_VALUE_MUST_BE_POSITIVE | quantity is 0, negative or not finite |
INVALID_QUANTITY_DECIMAL_FORMAT | quantity is not a decimal number |
MISSING_OCCURRED_AT | No occurred_at |
INVALID_OCCURRED_AT_TIMESTAMP | occurred_at is not a valid timestamp |
FOREIGN_KEY_VIOLATION_ORGANIZATION_OR_PROJECT_NOT_FOUND | The organization is not a customer of your application |
ORGANIZATION_OR_RESOURCE_NOT_FOUND | The organization or the capability was not found |
INTERNAL_INGESTION_ERROR | The region could not record the event; contact us with the usage_event_id |
Most reasons point at a bug in the code that builds the event: log them, and fix the event rather than resending it. An event sent again with the same usage_event_id after it was accepted is counted once.
The backlog
While the region is unreachable, the events of a failed send move into a backlog, and the first send back online delivers the backlog before new events.
| Mode | Survives a restart of your process | Where the events wait |
|---|---|---|
memory | No | In the process (default) |
journal | Yes | In an append-only file, backlog.journal_path, replayed on start |
host_adapter | Your store | In the BacklogAdapter you pass as backlog.adapter |
BacklogAdapter
For 'host_adapter', implement this interface on a store all your replicas share, for example Redis or your database:
import type { BacklogAdapter, BacklogItem, UsageEventItem } from '@shiprev/server-sdk-node';
export interface BacklogAdapter {
/** Store one event that could not be delivered. */
enqueue(event: UsageEventItem): Promise<void>;
/** Return up to `limit` of the oldest stored items, without removing them. */
peek(limit: number): Promise<BacklogItem[]>;
/** Remove the items with these IDs: they were delivered or refused. */
ack(ids: string[]): Promise<void>;
/** Keep the items with these IDs for a later attempt, and count one more attempt on each. */
nack(ids: string[]): Promise<void>;
/** How many items are stored. */
size(): Promise<number>;
}
// One stored event: the ID is yours to assign in enqueue().
interface BacklogItemShape {
id: string;
event: UsageEventItem;
enqueued_at: number; // milliseconds since the epoch
attempts: number;
}peek() must return items oldest first, and ack() and nack() receive IDs that peek() returned. Every event carries its usage_event_id, so an event delivered twice because of a crash between delivery and ack() is counted once.
const shiprev = createClient({
base_url: process.env.SHIPREV_BASE_URL!,
api_key: process.env.SHIPREV_API_KEY!,
backlog: { mode: 'host_adapter', adapter: new RedisBacklog(redis) }, // your implementation
});Client state
state() returns { state, snapshot_revision, last_sync_at }, and is meant for your health endpoint:
{ "state": "LIVE", "snapshot_revision": 42, "last_sync_at": 1767225600000 }snapshot_revision is the revision of the mirror; last_sync_at is the time of its last update in milliseconds since the epoch, absent before the first one.
| State | Meaning | Reads | Writes |
|---|---|---|---|
COLD | Not started, or stopped | As offline: from the last snapshot if one was loaded, otherwise decided by presets.cold_start | Refused: disconnected_write |
LIVE | Online, mirror loaded | From the mirror | From the mirror |
DISCONNECTED | Push stream down, last snapshot kept | Allowed from the last snapshot | Refused: disconnected_write |
COLD_OFFLINE | Offline, nothing loaded | Decided by presets.cold_start | Refused: disconnected_write |
The push stream is the only online signal. The client renews its service token before it expires and once on any 401, and reconnects with backoff (1, 2, 4, 8 and 16 seconds, then every 30 seconds). Back online, it reloads the whole snapshot and delivers the backlog before new events. Its timers never keep your process alive.
Session tokens
verifyToken() resolves to TokenClaims: account_id, organization_id, project_id, tenant_id, key_version, scope, exp, and the standard claims iat, iss, sub and aud when the token carries them. Rule and report with organization_id from these claims.
TokenRejectedError.reason
A correctly signed, unexpired token that this client does not accept throws TokenRejectedError. Its message names the reason and never contains the token.
| Reason | Meaning |
|---|---|
not_ready | The client has not reached the region yet, so it does not know which issuer and application to expect. Every token is refused until start() has exchanged the SDK key once |
audience_mismatch | Not an end-user session token: the audience or scope is not the public one |
issuer_mismatch | Issued by another region or sign-in gate than your application's |
project_mismatch | Issued for another application |
not_a_session_token | A one-time token, such as an email verification link |
missing_expiry | The token has no expiry |
NestJS bindings
ShipRevModule.forRoot(options)andShipRevModule.forRootAsync()register one global client and start it in the background, so your service boots even while the region is unreachable.ShipRevAuthGuardreadsAuthorization: Bearer <session token>, verifies it withverifyToken(), and puts the claims on the request asauthClaims. Any failure is a401.ShipRevCapabilityGuardrules withadjudicateAsync()on whatRequireCapability()names, for the organization in the verified claims only, and puts the ruling on the request asruling. A refusal is a403; claims without an organization are a403before any ruling. A route withoutRequireCapability()passes this guard.RequireCapability(capabilityKey, operation, quantity):operationdefaults to'read'andquantityto 1. Name'write'for anything that consumes quota, changes state or costs money.InjectShipRevClient()injects the client.
Errors
| Thrown by | Error | When |
|---|---|---|
verifyToken() | TokenRejectedError | A correctly signed, unexpired token of the wrong kind; see TokenRejectedError.reason above |
verifyToken() | Error | A malformed token, a bad signature, an unknown signing key or an expired token |
guardWrite() | DisconnectedWriteError | The write ruling refused the call, offline or for any other reason; the message names the reason |
reportUsage() | RangeError | A quantity that is not a finite number |
confirm() | Error | No actualAmount for a reservation this client did not make |
reserve(), confirm(), release() | Error with a numeric status | The region answered with an HTTP error; status is the HTTP status and the message carries the response body, with its error code |
Treat every failure of verifyToken() as a 401. The error codes the region can return are listed in Error codes.