The Caerus client for Node.js. Reserve limited stock — seats, slots, inventory — without writing the concurrency logic that keeps two customers from buying the same thing.
const holder = await caerus.unitary('seat_A12').take();
await chargeCard(4500);
await caerus.confirm(holder.id);The seat is held while the payment runs, and taken for good once it settles. If anything
goes wrong, release puts it straight back on sale — and if your process dies before it
can, the hold expires on its own.
Selling something there is only one of is harder than it looks. Two people click buy at the same moment; one payment takes eight seconds; a third customer abandons the page with the seat still held. Getting that right means distributed locks, timeouts, and a background job to clean up what was abandoned.
Caerus is that machinery as a service. This package is how you talk to it.
It runs on your server. The API Key authenticates every call and identifies your environment, so it cannot go anywhere a browser could read it.
npm install @caerus-dev/sdkNode 20 or newer. TypeScript types are included; JavaScript works too.
import { CaerusClient } from '@caerus-dev/sdk';
const caerus = new CaerusClient({
endpoint: process.env.CAERUS_ENDPOINT!, // "host:puerto" — see below
apiKey: process.env.CAERUS_API_KEY!,
});A host and a port, separated by a colon. Nothing else.
engine.tu-caerus.com:9090 ✓
localhost:9090 ✓ a local engine
https://engine.tu-caerus.com ✗ no scheme
engine.tu-caerus.com ✗ no port
localhost:9090/sre ✗ no path
It is not a URL, and that trips people up. Caerus speaks gRPC, not HTTP, so there is no
https:// and no path — just the address of the machine and the port it listens on.
Where to get it. Running Caerus yourself, it is the address of the data plane; in a
local setup, localhost:9090. Using someone else's, ask them: it is not something you
can guess, and it is not on the dashboard.
There is deliberately no default. One baked into this package would reach you as a connection error with nothing to suggest it was never meant to work.
A local engine listens in plaintext, so pair it with tls: false. Forget that and
the failure is a bare OpenSSL message about a wrong version number, which says nothing
about what is actually wrong.
const caerus = new CaerusClient({
endpoint: 'localhost:9090',
apiKey: process.env.CAERUS_API_KEY!,
tls: false,
});| Option | Default | What it does |
|---|---|---|
endpoint |
— | host:puerto of the engine, no scheme and no path. Required |
apiKey |
— | From the Caerus dashboard. Required. Identifies your environment too |
tls |
true |
Encrypts the connection. Turn off only against a local engine |
timeoutMs |
10000 |
Deadline on every call |
logger |
console.error |
Where the SDK reports things it handled but you should know about |
Build one client and keep it. It holds a connection that every call shares; making one per request throws that away.
caerus.close(); // on shutdown. Node stays alive while the connection is openFour seats on sale, one of them sold.
import { CaerusClient } from '@caerus-dev/sdk';
const caerus = new CaerusClient({
endpoint: 'REPLACE_ME.caerus.example:9090',
apiKey: process.env.CAERUS_API_KEY!,
});
// Once, when the show goes on sale. "seat" is a template you made in the dashboard.
for (const number of [1, 2, 3, 4]) {
await caerus.createUnitary('seat', `seat_A${number}`, { groupKey: 'row_A' });
}
// Every time somebody buys.
const holder = await caerus.unitary('seat_A1').take({ ttlSeconds: 120 });
try {
const { paymentId } = await chargeCard(4500);
await caerus.confirm(holder.id, { metadata: { paymentId } });
} catch (error) {
await caerus.release(holder.id); // straight back on sale
throw error;
}
// seat_A1 is now confirmed and off the market.
const row = await caerus.getResourcesByGroup('row_A');
console.log(row.resources.filter((seat) => seat.availableAmount > 0).length); // 3Release on every path that abandons a checkout. Without it the seat stays held until its TTL runs out — correct, but minutes of stock nobody can buy.
More in examples/01-holding-a-resource.ts, in the repository.
You take through a handle, and the handle says what kind of resource it is.
const seat = caerus.unitary('seat_A12'); // one of it
await seat.take(options?);
const pool = caerus.pooled('general_admission'); // many of it
await pool.take(options?);
await pool.takeMany(4, options?);unitary has no takeMany. Asking for three of a numbered seat does not compile —
which is the point of splitting them.
Making a handle fetches nothing. It records what you know the resource to be, so the type holds wherever the taking happens, which is rarely the service that declared the inventory in the first place.
That also means the SDK believes you.
pooled('seat_A12').takeMany(3)compiles, and the engine refuses it at runtime just as it would have before. This is a safety net, not a guarantee.
confirm(resourceHolderId, options?) // settle it: the units stay taken
release(resourceHolderId) // give them back early
extend(resourceHolderId, extraMs) // push the expiry out
getResourceHolder(resourceHolderId) // read it as it standsThese take a holder id rather than a resource, which is why they live on the client and not on the handles.
options for take and takeMany:
| Field | What it does |
|---|---|
idempotencyKey |
Sending the same key twice returns the first holder instead of taking more. Some templates require it |
ttlSeconds |
Overrides the template's hold time |
metadata |
An object of your own that travels with the holder |
⚠️ extendtakes milliseconds,ttlSecondstakes seconds. That mismatch is in the contract, not something this SDK invented, so it is passed through rather than papered over. The engine works in whole seconds and rounds up: anything under 1000 buys exactly one second.
More in examples/02-holders.ts, in the repository.
{
id: 'hld_...',
resourceId: 'res_...',
status: 'PENDING',
amount: 1,
expiresAt: Date, // a real Date, not epoch seconds
metadata: { orderId: 'ord_1234' }, // an object, not a JSON string
}| Status | What it means |
|---|---|
PENDING |
Held and waiting for you to confirm or release |
CONFIRMED |
Settled. The units are taken for good |
RELEASED |
Given back |
QUEUED |
No stock; the engine parked the request. Nothing is held yet |
FAILED |
The hold did not survive — it expired, most likely |
take throws on FAILED rather than handing back something that looks successful.
getResourceHolder returns it: asking what state something is in and being told FAILED is
an answer, not a failure.
createUnitary(templateName, key, options?) // one unit, no amount
createMultiple(templateName, key, availableAmount, options?) // several
getResource(key)
getResourcesByGroup(groupKey, options?) // { page?, pageSize? }Templates — hold duration, whether metadata is kept, what happens when stock runs out — live in the Caerus dashboard. The concrete resources that follow those rules are created here, and only here.
More in examples/03-inventory.ts, in the repository.
Every error extends CaerusError, so one catch handles them all. Each keeps the
message the engine sent.
| Error | code |
When |
|---|---|---|
ResourceNotFoundError |
RESOURCE_NOT_FOUND |
No such resource, template or holder |
ConflictError |
CONFLICT |
The state does not allow it — including no stock |
ValidationError |
VALIDATION |
The request was rejected |
AuthenticationError |
AUTHENTICATION |
API Key missing, unknown or revoked |
TimeoutError |
TIMEOUT |
The call ran past its deadline |
CaerusError |
UNKNOWN |
Anything else |
try {
await caerus.unitary('seat_A12').take();
} catch (error) {
if (error instanceof ConflictError) {
// Sold out, or the holder was in the wrong state — see Known limitations
}
}More in examples/04-errors.ts, in the repository.
InMemoryCaerusClient implements the same interface as the real client, so the code
under test cannot tell them apart. It keeps real stock: a test that takes more than
exists fails the way production would.
import { InMemoryCaerusClient, type SharedResourceApi } from '@caerus-dev/sdk';
// Take the interface, not the class.
async function buySeat(caerus: SharedResourceApi, seat: string) {
const holder = await caerus.unitary(seat).take();
await chargeCard(4500);
await caerus.confirm(holder.id);
}
const caerus = new InMemoryCaerusClient({
resources: [{ key: 'seat_A12', availableAmount: 1 }],
});
await buySeat(caerus, 'seat_A12');Time does not pass on its own, which is the point:
const holder = await caerus.unitary('seat_A12').take({ ttlSeconds: 300 });
caerus.advanceTime(301); // 301 seconds later
await caerus.getResourceHolder(holder.id); // status: 'FAILED'A mock that expired holders on a real clock would make your tests wait, and fail now and then depending on how busy the machine was. Here time is an input, like the stock.
Forcing failures, for the paths you cannot otherwise reach:
caerus.failNext('release', new ConflictError('release exploded'));
caerus.clearFailures();Also available: expire(resourceHolderId) for one holder, and snapshot() for
assertions the API cannot make.
More in examples/05-testing.ts, in the repository.
Nothing here is a bug report — it is what this version does not do, so you can plan around it.
If the network fails mid-call, the SDK cannot know whether the engine processed the
request. Re-sending a take could hold the stock twice; re-sending an extend would add
the time twice, silently. Since Caerus exists precisely so that two people cannot buy the
same seat, an SDK that could cause that would contradict the product.
What it does instead: every call has a deadline, and idempotencyKey lets you retry
a take safely yourself. Read-only calls are harmless and may be retried freely.
The engine reports no-stock and every other invalid state with the same code, so both
become ConflictError. Telling them apart means reading the message text, which will
break the first time the wording changes.
In the meantime: check the stock with getResource before deciding what to tell your
customer, rather than parsing the message.
If whatever you do between take and confirm takes longer than the hold, the
holder expires while you are still working and the confirm afterwards fails. Your
work already happened — the payment went through — but the seat went back on sale in the
middle.
This is correct behaviour and still a surprise. It is a real risk with slow payment providers and short hold times.
Avoid it by setting ttlSeconds above your payment provider's worst case, not its
average, and by calling extend when you are about to run out.
It is faithful to how we understand Caerus, which is not the same as being faithful to Caerus. Specifically:
QUEUEDis not simulated. Out of stock always throwsConflictError, like theFAILstrategy. Templates that queue cannot be exercised against it- Template rules are not enforced. A single-unit template requiring exactly 1, or a template that rejects metadata, will be accepted here and refused by the engine
templateIdis made up (tpl-<name>), so it will not match a real one- It is single-threaded, so it says nothing about races
Treat a green test suite against the mock as evidence your code is wired correctly, not as evidence it works against Caerus.
This version has no way to verify that a call came from where it says. There is nothing to do about it here; it is noted so it is not mistaken for a guarantee.
The gRPC client is generated from proto/sre_service.proto before every build and is
not committed, so it cannot drift from the contract. That .proto is a copy of the one
in caerus-back; see proto/README.md.
npm install
npm run generate # produces src/generated and src/version.ts
npm run typecheck # source, tests and every example
npm test
npm run build # CJS, ESM and .d.tsTwo rules the build enforces:
- The examples in
examples/are compiled. Change the API and forget an example, and the build fails. Documentation that cannot go stale quietly - Nothing generated from the
.protomay appear in the published types.npm run buildchecksdist/*.d.tsand fails if it finds any
Deeper documentation lives in docs/ — the Caerus model, how the SDK is put
together, the error mapping, and how to run it against a real engine. It is written in
Spanish, for the teams building on Caerus. If you are going to change this package,
AGENTS.md is the place to start.
MIT