Your backend does three things with ShipRev: it verifies the session token of each request, rules on whether the caller's organization may use a capability, and reports what was consumed. All three run inside your process. The network is used only to keep a local mirror of your customers' subscriptions current and to deliver usage.
The Server SDK is published for Node.js, with bindings for NestJS. The code below is TypeScript.
1. Install
npm install @shiprev/server-sdk-node@betaNode.js 20 or later. For the NestJS bindings, your service also needs @nestjs/common and @nestjs/core (9, 10 or 11) and reflect-metadata; a plain Node.js service does not.
2. Configure the values from the console
The application's page in the developer console shows these three lines ready to copy:
SHIPREV_BASE_URL=<the API address of your application>
SHIPREV_API_KEY=<the secret key shown once at creation>
SHIPREV_APP_ID=<the application ID>SHIPREV_BASE_URL is the application's API address exactly as the console shows it. SHIPREV_API_KEY is the SDK key saved in Application and region. The SDK needs only these two; SHIPREV_APP_ID is the application's ID for your own reference (the key already identifies the application), and you can keep or drop it. Keep the key out of the browser and out of the repository.
3. Start the client
Without NestJS, create and start the client yourself. start() never throws and reports where it ended:
import { createClient } from '@shiprev/server-sdk-node';
export const shiprev = createClient({
base_url: process.env.SHIPREV_BASE_URL!,
api_key: process.env.SHIPREV_API_KEY!,
});
const { state } = await shiprev.start(); // 'LIVE' once the region answered and the mirror is loaded
// ...
await shiprev.stop(); // sends buffered usage before exitingIf start() does not reach LIVE, check that SHIPREV_BASE_URL is the API address exactly as shown and that the key is the SDK key of this application.
With NestJS, register the module once. It is global, provides one client, and starts it in the background so your service boots even when the region is unreachable:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ShipRevModule } from '@shiprev/server-sdk-node/nest';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ShipRevModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
base_url: config.getOrThrow<string>('SHIPREV_BASE_URL'),
api_key: config.getOrThrow<string>('SHIPREV_API_KEY'),
}),
}),
],
})
export class AppModule {}Stop the client on shutdown so buffered usage is sent: implement OnApplicationShutdown, call stop() on the injected ShipRevClient, and call app.enableShutdownHooks() in main.ts.
4. Protect routes: token, then capability
import { Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { type Ruling, ShipRevClient, type TokenClaims } from '@shiprev/server-sdk-node';
import {
InjectShipRevClient,
RequireCapability,
ShipRevAuthGuard,
ShipRevCapabilityGuard,
} from '@shiprev/server-sdk-node/nest';
/** What the two guards leave on the request once they let it through. */
interface GuardedRequest {
authClaims: TokenClaims;
ruling: Ruling;
}
@Controller('api/reports')
@UseGuards(ShipRevAuthGuard, ShipRevCapabilityGuard)
export class ReportsController {
constructor(@InjectShipRevClient() private readonly shiprev: ShipRevClient) {}
@Get()
@RequireCapability('report_exports', 'read', 0)
list(@Req() request: GuardedRequest) {
return { organization_id: request.authClaims.organization_id, ruling: request.ruling };
}
@Post('export')
@RequireCapability('report_exports', 'write', 1)
async export(@Req() request: GuardedRequest) {
// ... do the work, then report it (step 6)
}
}ShipRevAuthGuardverifiesAuthorization: Bearer <session token>and puts the claims onrequest.authClaims. The guard turns every verification failure into a401: a missing, malformed, expired or refused token.ShipRevCapabilityGuardrules on the capability named by@RequireCapability(key, operation, quantity)for the caller's organization and puts the ruling onrequest.ruling. A refusal is a403; the handler is not called.- The guards are applied in that order. A route without
@RequireCapabilitypasses the second guard: it is protected by the token check only, and no entitlement is ruled on. - The organization ruled on is taken only from the verified token. The capability guard never reads it from a header, route parameter or body field, because the caller controls those; a token without an organization is refused with
403before any ruling.
An unannotated route is identity-checked only
The capability guard does not refuse routes that lack @RequireCapability; it lets them through. Apply both guards at controller level, as above, so that every route is at least token-checked, and annotate every route that consumes quota, changes state or costs money. A new route added without the decorator is reachable by any signed-in end user of the application regardless of their plan, so treat the decorator as part of the route's definition and cover it in your tests.
Verification is local: the SDK checks the signature against your region's keys, the expiry, and that the token is an end-user session token of your application. A token from another application is refused with TokenRejectedError; its reason says which check failed. There is nothing to configure: the expected issuer and application come from the service token the client obtains with your SDK key. Until the client has reached the region once, every token is refused with not_ready; after that, verification keeps working offline.
Outside NestJS, do the same two steps yourself: verify the token, then rule with the organization taken from the verified claims. adjudicate() is synchronous and does no I/O.
import { TokenRejectedError } from '@shiprev/server-sdk-node';
import { shiprev } from './shiprev';
export async function exportReport(authorization: string | undefined) {
const token = authorization?.replace(/^Bearer /, '');
if (!token) return { status: 401 };
let claims;
try {
claims = await shiprev.verifyToken(token); // signature, expiry, application
} catch (error) {
// Every failure of verifyToken() is a 401: a malformed, badly signed or expired token throws a plain Error,
// and a token that is not a session token of your application throws TokenRejectedError with a reason.
const reason = error instanceof TokenRejectedError ? error.reason : 'invalid_token';
return { status: 401, reason };
}
// The organization comes from the verified token and from nowhere else.
const ruling = shiprev.adjudicate({
organization_id: claims.organization_id,
capability_key: 'report_exports',
quantity: 1,
operation: 'write',
});
if (!ruling.allowed) return { status: 403, reason: ruling.reason };
// ... do the work, then report it with the same claims.organization_id (step 6)
return { status: 200 };
}Never take organization_id from a header, a query or route parameter, or the request body: the caller controls those, and a value copied from them would rule on, and later bill, whichever organization the caller named. The same applies to reportUsage().
5. Rulings and operation
Every ruling names an operation, 'read' or 'write', because the two behave oppositely when the region is unreachable:
| Client state | When | read | write |
|---|---|---|---|
LIVE | Online, mirror loaded | Ruled from the mirror | Ruled from the mirror |
DISCONNECTED | Push stream down, last snapshot kept | Allowed from the last snapshot | Refused: disconnected_write |
COLD_OFFLINE | Offline, no snapshot loaded | presets.cold_start decides (deny by default) | Refused: disconnected_write |
Anything that consumes quota, changes state or costs money is a write. A read that only needs the capability granted can pass quantity: 0. The ruling's source (realtime, authoritative, degraded, client_default) tells you where it came from, and remaining what is left.
6. Report usage
After the work is done, report it:
import { randomUUID } from 'node:crypto';
await this.shiprev.reportUsage({
usage_event_id: randomUUID(),
organization_id: request.authClaims.organization_id,
capability_key: 'report_exports',
quantity: 1,
occurred_at: new Date().toISOString(),
});reportUsage() appends to an in-memory buffer that is sent in batches: every 100 events, every 5 seconds, on flush() and on stop(). usage_event_id is your unique ID for the event; the region drops repeats, so a retried event counts once. Events the region refuses go to the on_rejected callback and are not retried.
For work whose cost is only known afterwards, reserve first and settle later with reserve(), then confirm() or release().
7. Subscriptions reach you without polling
From start() to stop() the client keeps itself connected: it holds a push stream open, renews its service token before it expires, and reconnects with backoff. When a customer's subscription changes, whether they moved to another plan or their payment failed, the region announces it and the client refreshes that organization at once. The next ruling uses the new entitlements without a restart of your process.
While the region is unreachable, usage is not lost: flushes move events into a backlog, and the first flush back online delivers the backlog before new events. Choose where the backlog lives with backlog.mode:
| Mode | Survives a restart of your process | Use when |
|---|---|---|
memory | No (default) | Losing the usage of an outage that coincides with a restart is acceptable |
journal | Yes | Your process has a writable local disk; an append-only file, replayed on start |
host_adapter | Your store | You run several replicas or have no durable disk; implement BacklogAdapter on Redis or your database |
Expose state() ({ state, snapshot_revision, last_sync_at }) in your health endpoint so you can see the client's state in production.
Rotate the SDK key, or replace a lost one
The developer console shows the key once and cannot show it again. New keys are created, and old ones revoked, in the application's operator console under Settings, Project Credentials; only owners and admins can do either. Rotate in this order so that your service never runs without a valid key:
- Create a new Server SDK key there. It is shown once; save it as you did the first one.
- Deploy your service with the new key in
SHIPREV_API_KEYand check thatstart()reachesLIVEagain (the health endpoint from step 7 shows it). - Revoke the old key on the same page. From that moment the region refuses to exchange it for a service token, so any process still configured with it cannot start, reconnect or renew. Revocation cannot be undone.
A service token that a process had already exchanged with the old key before the revocation stays valid until it expires, up to one hour; the client renews it before then and is refused at that point. If you suspect the key has leaked, revoke it first and accept the interruption: the leaked key stops working at once, and the window in which an already-issued token can still be used is bounded by that expiry.
Checklist
- The SDK key is a server-side secret and never reaches the browser.
- Every guarded route names the right
operation; anything that consumes quota is awrite. - Every reported event has a unique
usage_event_id. - The client is stopped on shutdown.
- The backlog mode matches how your service is deployed.
Next
Run a subscription in test mode: define a plan, create a test customer and watch the first usage event arrive.