Reference

Server SDK reference

Options, methods, rulings, refusal reasons and errors of the Server SDK for Node.js and NestJS, version 0.1.0-beta.0.

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.

ImportContents
@shiprev/server-sdk-nodecreateClient(), ShipRevClient, the types, TokenRejectedError, DisconnectedWriteError, and the backlogs MemoryBacklog, JournalBacklog and HostAdapterBacklog
@shiprev/server-sdk-node/nestShipRevModule, 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.

OptionDefaultMeaning
base_urlrequiredYour application's API address, as the developer console shows it
api_keyrequiredYour 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_ms5000Timeout of every request to the region, in milliseconds
presets.reservation_ttl_sset by the regionDefault lifetime of a reservation, in seconds
buffer.batch_size100Usage events per request; reaching it sends the buffer at once
buffer.flush_interval_ms5000How 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 directoryThe append-only file of the 'journal' mode
backlog.adapternoneYour BacklogAdapter, required by the 'host_adapter' mode
on_rejectednoneCalled with the usage events the region refused; see Refused usage events
on_flush_failurenoneCalled 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.

ts
const shiprev = createClient({
  base_url: process.env.SHIPREV_BASE_URL!,
  api_key: process.env.SHIPREV_API_KEY!,
  presets: { cold_start: 'allow' },
});

Client methods

MethodWhat 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()

ts
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()

ts
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:

ts
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;
}
MethodResolves to
reserve(options){ reservation_id, status, expires_at }
confirm(reservationId, actualAmount){ reservation_id, status, confirmed_amount }
release(reservationId){ reservation_id, status }
StatusMeaning
PENDINGReserved and waiting to be confirmed or released
CONFIRMEDSettled with confirm()
RELEASEDGiven back with release()
RECLAIMEDNot 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:

FieldTypeMeaning
allowedbooleanWhether to go ahead
sourcestringWhere the ruling came from; see below
reasonstring, optionalWhy the call was refused; see below
remainingnumber, optionalWhat 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
limitnumber, optionalThe allowance of the current period
adjudicated_atnumberMilliseconds since the epoch

ruling.source

SourceWhen
realtimeOnline; ruled from the local mirror
authoritativeOnline; the region ruled, because the mirror did not know the organization or capability (adjudicateAsync() only)
degradedThe push stream is down; ruled from the last snapshot
client_defaultNo basis for a ruling: an invalid request, a cold start, or an organization or capability the mirror does not know

ruling.reason

ReasonSourceMeaningWhat to do
quota_exhaustedrealtime, authoritativeThe call goes beyond what is left of the allowance, and the capability refuses beyond itRefuse the call, or offer an upgrade
capability_not_grantedclient_defaultOnline, but the mirror holds no such capability for the organization. adjudicateAsync() then asks the regionUse adjudicateAsync(), or check the capability key and the customer's plan
capability_not_foundauthoritativeThe region confirms the organization's plan does not grant the capabilityRefuse the call
disconnected_writedegraded, client_defaultA write while the region is unreachableRefuse or retry later; writes are never allowed offline
cold_start_deniedclient_defaultA read on a cold start, with presets.cold_start set to 'deny'Retry once start() has reached LIVE
invalid_requestclient_defaultoperation missing or unknown, or quantity negative or not finiteFix 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.

ts
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');
  },
});
ReasonMeaning
MALFORMED_EVENT_PAYLOADThe event is not an object
MISSING_USAGE_EVENT_IDNo usage_event_id; the event is reported with usage_event_id unknown
reserved_identifierThe usage_event_id starts with a prefix ShipRev reserves for its own use
MISSING_ORGANIZATION_IDNo organization_id
MISSING_CAPABILITY_KEYNo capability_key
MISSING_QUANTITYNo quantity
INVALID_QUANTITY_VALUE_MUST_BE_POSITIVEquantity is 0, negative or not finite
INVALID_QUANTITY_DECIMAL_FORMATquantity is not a decimal number
MISSING_OCCURRED_ATNo occurred_at
INVALID_OCCURRED_AT_TIMESTAMPoccurred_at is not a valid timestamp
FOREIGN_KEY_VIOLATION_ORGANIZATION_OR_PROJECT_NOT_FOUNDThe organization is not a customer of your application
ORGANIZATION_OR_RESOURCE_NOT_FOUNDThe organization or the capability was not found
INTERNAL_INGESTION_ERRORThe 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.

ModeSurvives a restart of your processWhere the events wait
memoryNoIn the process (default)
journalYesIn an append-only file, backlog.journal_path, replayed on start
host_adapterYour storeIn 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:

ts
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.

ts
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:

json
{ "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.

StateMeaningReadsWrites
COLDNot started, or stoppedAs offline: from the last snapshot if one was loaded, otherwise decided by presets.cold_startRefused: disconnected_write
LIVEOnline, mirror loadedFrom the mirrorFrom the mirror
DISCONNECTEDPush stream down, last snapshot keptAllowed from the last snapshotRefused: disconnected_write
COLD_OFFLINEOffline, nothing loadedDecided by presets.cold_startRefused: 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.

ReasonMeaning
not_readyThe 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_mismatchNot an end-user session token: the audience or scope is not the public one
issuer_mismatchIssued by another region or sign-in gate than your application's
project_mismatchIssued for another application
not_a_session_tokenA one-time token, such as an email verification link
missing_expiryThe token has no expiry

NestJS bindings

  • ShipRevModule.forRoot(options) and ShipRevModule.forRootAsync() register one global client and start it in the background, so your service boots even while the region is unreachable.
  • ShipRevAuthGuard reads Authorization: Bearer <session token>, verifies it with verifyToken(), and puts the claims on the request as authClaims. Any failure is a 401.
  • ShipRevCapabilityGuard rules with adjudicateAsync() on what RequireCapability() names, for the organization in the verified claims only, and puts the ruling on the request as ruling. A refusal is a 403; claims without an organization are a 403 before any ruling. A route without RequireCapability() passes this guard.
  • RequireCapability(capabilityKey, operation, quantity): operation defaults to 'read' and quantity to 1. Name 'write' for anything that consumes quota, changes state or costs money.
  • InjectShipRevClient() injects the client.

Errors

Thrown byErrorWhen
verifyToken()TokenRejectedErrorA correctly signed, unexpired token of the wrong kind; see TokenRejectedError.reason above
verifyToken()ErrorA malformed token, a bad signature, an unknown signing key or an expired token
guardWrite()DisconnectedWriteErrorThe write ruling refused the call, offline or for any other reason; the message names the reason
reportUsage()RangeErrorA quantity that is not a finite number
confirm()ErrorNo actualAmount for a reservation this client did not make
reserve(), confirm(), release()Error with a numeric statusThe 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.

© 2026 ShipRev