npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@uengage.io/platform-sdk

v6.0.0

Published

Client SDK for the uEngage platform API (audit, business, zones, wallet, upsell, vault, auth, events, alerts). Single createClient(...) factory; OAuth2 client_credentials, static Bearer, or legacy session-token auth modes. Browser-safe realtime client at

Readme

@uengage.io/platform-sdk

Backend-issued public/private suggestion tokens and outlet, brand or global admin tokens: upsell token guide.

Client SDK for uEngage platform services. One factory (createClient) returns a PlatformClient exposing business, audit, zones, wallet, upsell, vault, alerts, and auth. No singletons, no module-level state - each consumer constructs its own client with explicit config.

Restricted realtime channels

Call auth.mintRealtimeToken({ clientId, clientSecret, channel: 'delivery-updates', expiresIn: 300, authBaseUrl }) on your backend after authorizing the current user. The registered client needs realtime.tokens:issue. Return its { token, expiresIn, channel } result from your own non-cacheable token route. On the frontend, supply the token through realtime.connect({ getToken, env }) and call client.subscribe('delivery-updates', ...).

Names are case-sensitive identifiers, not paths or wildcards. Tokens permit exactly one name, are valid for 60–14400 seconds (300 by default), and cannot publish or call other platform APIs. Minting does not create a publisher. See the complete integration and authorization guide.

Quick start

import { createClient, Services } from '@uengage.io/platform-sdk';

// No args: read everything from UENGAGE_* env vars (baseUrl, service
// credentials or auth token, etc.). Convenient for Lambdas where the
// deploy stack already injects the right env.
const client = createClient();

// Or override per call:
const explicit = createClient({
  baseUrl: 'https://api.platform.uengage.io',
  serviceId: 'my-service',
  serviceSecret: process.env.MY_SERVICE_SECRET!,
  scope: 'business.profile:read business.menu:read',
  actorVia: 'my-service', // stamped into audit events
});

const acme = await client.business.get(123, { groups: ['profile'] });

// Wallet (requires a service token with the relevant wallet.* scope).
// getWallet(...) is a lightweight handle; operations resolve the wallet
// lazily and name the wallet that answered. forceChildWallet:true reads/
// charges the business's own wallet regardless of parent-funding routing.
const wallet = client.wallet.getWallet({ id: 'business:123' });
const { balance, balanceMinor, currency } = await wallet.getBalance();
// wallet.getInstance() / wallet.getCurrency() — wallet identity + currency, without the balance
// wallet.debit({ referenceId, amountMinor, service: Services.FLASH_DELIVERY, description }) — idempotent on referenceId
// wallet.credit({ ..., reversalOf }) refund capped by that debit; ({ ..., isRefund: true }) uncapped
// wallet.listTransactions({...}); wallet.getTransaction(id)
// wallet.getOverview() — wallet balance or credit line? See Wallet client below.
// Legacy-ledger passthroughs, written as top-level ledger fields:
//   taskId, rto (debit), units, serviceBaseCost, paymentId (credit), updatedBy,
//   occurredAt — IST 'YYYY-MM-DD HH:mm:ss', dates the CHARGE not the write
//   (backfills/replays); ≤90 days back, never future. Write time → recordedAt.
// wallet: {parentBusinessId, childBusinessId} on a write bypasses bId_deduction routing;
//   a child override's parentBusinessId is asserted against the wallet doc (409 on mismatch).

client.audit.record({
  event_type: 'business.updated',
  tenant: { id: 'biz_123', parent_id: null },
  actor: { type: 'user', id: 'usr_42' },
  resource: { type: 'business', id: 'biz_123' },
  changes: { name: { before: 'Old', after: 'New' } },
});

Auth modes

createClient takes exactly one of three auth modes (or none, for fully-public calls). Passing more than one throws ConfigError.

// Service-to-service (OAuth2 client_credentials)
createClient({ serviceId: 'svc', serviceSecret: 'sup', scope: '...' });

// Static Bearer (caller already minted the token, e.g. a BFF that has
// the end user's access_token from cookie / session)
createClient({ authToken: req.headers.authorization!.slice(7) });

// Customer surface: legacy session_token → JWT (transparent rotation)
createClient({ sessionToken: req.cookies.uengage_session });

Each call returns a fresh, isolated client. Per-request scopes — say a BFF binding to the calling user's token — just call createClient again at the start of the handler.

Why no singleton?

Earlier versions of the SDK exported platform, audit, and business as module-level singletons that read configuration from process.env. That shape created problems in Node:

  • Module-load side effects. Importing the SDK eagerly constructed clients even when the caller didn't use them.
  • Cross-invocation state in Lambda. A singleton's token cache, JWKS cache, and fetch handle survived across invocations and could leak between tenants.
  • No multi-tenant story. A single Node process couldn't act as multiple identities; only one set of UENGAGE_* env vars was readable.
  • First-access config locking. process.env mutated after the first call was a no-op.
  • Tests needed a _resetPlatformSingleton() escape hatch.

Forcing every consumer to call createClient(...) removes all of that. Each instance is isolated, configuration is explicit, and the only state is what the caller chose to keep.

Configuration

| Field | Env override | Default | | --------------------- | -------------------------------- | --------------------------------- | | baseUrl | UENGAGE_BASE_URL | https://api.platform.uengage.io | | authBaseUrl | UENGAGE_AUTH_BASE_URL | ${baseUrl}/auth/business | | customerAuthBaseUrl | UENGAGE_CUSTOMER_AUTH_BASE_URL | ${baseUrl}/auth/customer | | serviceId | UENGAGE_SERVICE_ID | none | | serviceSecret | UENGAGE_SERVICE_SECRET | none | | scope | UENGAGE_SCOPE | none | | authToken | UENGAGE_AUTH_TOKEN | none | | getToken | (no env override) | none (vault token renewal) | | sessionToken | UENGAGE_SESSION_TOKEN | none | | actorVia | UENGAGE_ACTOR_VIA | falls back to serviceId | | expiryBufferMs | UENGAGE_EXPIRY_BUFFER_MS | 30000 | | fetchFn | (no env override) | globalThis.fetch |

createClient() reads env defaults; explicit fields passed to createClient(input) override the matching env value for that one call. Passing any auth-mode field explicitly (authToken, sessionToken, or the serviceId/serviceSecret pair) suppresses every env-derived auth default — so callers never get a silent merge of two different auth modes.

Audit client

client.audit.record({
  event_type: 'business.updated',
  tenant: { id: 'biz_123', parent_id: null },
  actor: { type: 'user', id: 'usr_42' },
  resource: { type: 'business', id: 'biz_123' },
  changes: { name: { before: 'Old', after: 'New' } },
});

await client.audit.flush(); // optional: drain on shutdown
  • record() is fire-and-forget. It generates event_id (ULID) and occurred_at, fills actor.via with the configured actorVia (or serviceId), validates the event, and enqueues it. It never throws.
  • Events batch and flush every 5 s (configurable) or when the batch reaches 50.
  • Failed batches retry up to 3 times with exponential backoff (500 ms / 2 s / 8 s); on final failure the full batch is logged.
  • Queue overflow (default 1000) drops new events with a warning log.
  • flush() resolves when the queue is empty or retries are exhausted.
  • If actorVia is unset, record() logs an error and drops the event.

Events client (platform event bus)

A subpath export, like /realtime, and for the same reason: it wraps @aws-sdk/client-sns, which is declared as an optional peer dependency so it is installed only by code that actually publishes. Consumers that just read businesses or zones — and any frontend bundle touching the root import — pay nothing. This mirrors the PHP SDK, where aws/aws-sdk-php is a suggest.

npm install @aws-sdk/client-sns   # only if you publish events
import { events, createEventsClient } from '@uengage.io/platform-sdk/events';

await events.publish(
  'order.status_changed',
  { orderId, orderType, status, deliveryStatus, statusRank },
  { tenantId },
);

await events.publishBatch([
  { type: 'order.created', data: { orderId: '1' }, tenantId: '38112' },
  { type: 'order.status_changed', data: { orderId: '1' }, tenantId: '38112' },
]);

events is a lazy singleton configured from the environment; use createEventsClient({ topicArn, source, snsClient }) for an isolated client. It is deliberately not on the object returned by createClient() — that would put the SNS dependency back on the main entry.

  • The SDK builds the whole envelope: monotonic ULID id, occurredAt, version, and domain (mapped from the type prefix — an explicit map, not a pluralisation rule, since menu, loyalty and usage are not plural).
  • type / domain / source are also set as SNS message attributes, which is what consumer filter policies match on.
  • Config is env-first: PLATFORM_EVENTS_TOPIC_ARN, optionally PLATFORM_EVENTS_SOURCE (defaults to SERVICE_NAME). Region is read from the topic ARN.
  • publish() throws — ConfigError, ValidationError, or EventPublishError (which carries failedEventIds). Fail-open is the caller's decision because it differs by call site: a legacy order flow must never block on the bus, a service reconciling state may want to retry. The PHP SDK makes that choice for you; here you make it.
  • publishBatch() validates and serializes every envelope before publishing anything, then chunks by both SNS limits: 10 entries and 256 KB per request. An envelope over the 256 KB message ceiling throws a ValidationError naming the event and its size.

Alerts client

Alerts have two halves that live in two places, for the same reason events is a subpath: raising sends over SQS, reading does not.

| What | Import | Transport | Needs | | --------------------------- | ------------------------------------ | ---------------------------------- | ----------------------------------------------------- | | Raise an alert | @uengage.io/platform-sdk/alerts | SQS FIFO queue alerts-{env}.fifo | @aws-sdk/client-sqs, sqs:SendMessage on the queue | | List / get / resolve / mute | createClient().alerts (main entry) | HTTP /v1/alerts | token with alerts:read / alerts:write |

Raising

import { alerts, createAlertsPublisher } from '@uengage.io/platform-sdk/alerts';

try {
  await alerts.raise({
    type: 'wallet.low_balance',
    title: 'Wallet balance low',
    description: 'Balance fell below ₹500.',
    tenant: { parentId: 5, businessId: 7175 },
    context: { balanceMinor: 42000, currency: 'INR' },
    notify: true, // stored only for now
  });
} catch (err) {
  logger.warn('alert not raised', { err }); // see "throws" below
}
  • Raising does not go through the platform event bus. The SDK validates the input (alertInputSchema), derives category from type (ALERT_TYPES — callers never send it), mints id (a monotonic ULID) and occurredAt (UTC, seconds precision), and sends the resulting AlertMessage as JSON straight to the alerts FIFO queue with MessageGroupId = alertMessageGroupId(msg) (parentId#businessId#type) and MessageDeduplicationId = msg.id. raise() resolves with the message it sent.
  • Tenant ids may be strings or positive integers; integers are stringified, so 7175 and "7175" are the same alert.
  • context is flat: string / finite number / boolean values, at most 50 keys.
  • Adding an alert type means adding it to ALERT_TYPES in alerts/schema.ts and releasing the SDK.
  • Queue URL, first match wins: the queueUrl option → PLATFORM_ALERTS_QUEUE_URL → the platform default for the env option or UENGAGE_ENV (uat | prod, see ALERT_QUEUE_URLS) → ConfigError. The region comes from the region option, else the queue URL's host, else AWS_REGION / AWS_DEFAULT_REGION.
  • alerts is a lazy singleton configured from the environment. createAlertsPublisher({ queueUrl, env, region, sqsClient }) builds an isolated one (sqsClient for tests or custom credentials).
  • raise() throws: ValidationError (bad input — nothing is sent), ConfigError (no queue URL), AlertPublishError (SQS failed; carries alertId and cause). Raising is usually a side effect of something more important, so most call sites should catch and log rather than fail the request.
  • buildAlertMessage(input) runs the same validation and derivation without sending.
  • IAM: the producer's execution role needs sqs:SendMessage on alerts-{env}.fifo. The queue lives in the platform account, so a cross-account producer must also be listed in the alerts queue policy — the same role list as the event bus producers (eventBusProducerRoleArns).

Matching. An alert is identified by (businessId, parentId, type). While an alert for that triple is open or muted, each new raise adds to it: count goes up, lastSeenAt / updatedAt move, and the latest title, description, context and notify win. Once it is resolved, the next raise starts a new alert (new id, count 1). The FIFO group per triple means the consumer never handles two raises for the same alert at once.

The /alerts subpath also re-exports the schema and HTTP client, so a producer can import everything alert-related from one place.

Listing and changing status

const client = createClient({ serviceId, serviceSecret, scope: 'alerts:read alerts:write' });
// For a browser, see "Frontend: alerts for one dashboard user" below.

let cursor: string | undefined;
do {
  const page = await client.alerts.list({ businessId: '7175', status: 'open', limit: 50, cursor });
  render(page.items);
  cursor = page.nextCursor ?? undefined;
} while (cursor);

const alert = await client.alerts.get('7175.9f2c…'); // id is opaque

await client.alerts.setStatus(alert.id, {
  status: 'resolved', // 'resolved' | 'muted' | 'open'
  comment: 'Merchant topped up',
  actor: 'usr_42', // the dashboard user; the calling service is trusted to pass it
});
  • list() needs at least one of businessId, parentId, type, category, status, userId (alerts that dashboard user can see; see Alert.userIds) or notify: true (only alerts whose latest event had notify on); status, limit (1–100, default 25) and cursor narrow further. Results are ordered by lastSeenAt, newest first.
  • setStatus() requires comment for resolved and muted. open ↔ muted and open | muted → resolved always work. Reopening a resolved alert (status: 'open') works only while no other open or muted alert exists for the same (businessId, parentId, type); otherwise the API answers 409 and setStatus() throws AlertsApiError with .status 409 and body error newer_alert_open. A reopen sets reopenedAt / reopenedBy (and reopenComment) and clears the resolved* fields. Transitions are enforced server-side.
  • Each alert carries count, firstSeenAt, lastSeenAt and updatedAt.
  • Scopes: alerts:read for list / get, alerts:write for setStatus.
  • Non-2xx responses throw AlertsApiError with .status and .body. Obviously bad arguments (no filter, empty id, missing comment) throw a plain Error before any request is sent.

Frontend: alerts for one dashboard user

A service token with alerts:read sees every business's alerts, so never hand one to a browser. Instead your backend mints an alerts user token for the logged-in dashboard user, and the frontend uses it with the browser-safe @uengage.io/platform-sdk/alerts/browser subpath.

Backend (the client needs alerts.tokens:issue plus each alerts:* scope it puts in tokens):

import { auth } from '@uengage.io/platform-sdk';

// Your own route, after authenticating the dashboard user
app.get('/api/alerts-token', async (req, res) => {
  const { token, expiresIn } = await auth.mintAlertToken({
    clientId: process.env.UENGAGE_SERVICE_ID!,
    clientSecret: process.env.UENGAGE_SERVICE_SECRET!,
    userId: String(req.user.id), // 1-64 printable ASCII; must match the audience rules' ids
    scopes: ['alerts:read'], // add 'alerts:write' to allow resolve / mute / reopen
    expiresIn: 900, // 60-14400 s, default 300
  });
  res.set('Cache-Control', 'no-store').json({ token, expiresIn });
});

Frontend:

import { createAlertsClient } from '@uengage.io/platform-sdk/alerts/browser';

const alerts = createAlertsClient({
  env: 'prod', // or 'uat', or baseUrl
  getToken: async () => (await fetch('/api/alerts-token')).json(), // string or { token }
});

const page = await alerts.list({ status: 'open' }); // no filter needed: always this user's alerts
const alert = await alerts.get(page.items[0].id);
await alerts.setStatus(alert.id, { status: 'resolved', comment: 'Topped up' }); // needs alerts:write
  • The token decides what is reachable. list always returns alerts whose userIds include the token's user (any userId is replaced server-side); get and setStatus answer 404 for any other alert.
  • Status changes are recorded as the token's user, so there is no actor.
  • getToken is called when there is no token, it is within 30 s of expiry, or the API answers 401 (the request is then retried once). Concurrent requests share one call.
  • auth.mintAlertToken throws ConfigError on bad input before sending, and AuthenticationError when the mint is refused or the response does not match the request. Tokens are never cached.

Realtime client (browser)

A separate, browser-safe subpath export — zero runtime dependencies, no Node built-ins, ~6 KB minified. Same client for web and React Native.

import { realtime } from '@uengage.io/platform-sdk/realtime';

const client = realtime.connect({
  env: 'prod', // resolves endpoints; no URLs in app code
  getToken: () => session.jwt(), // called on connect, resubscribe, and refresh
});

const sub = client.subscribe(`/orders/${orderId}`, {
  onUpdate(event, meta) {
    render(event.data);
  }, // meta.origin: 'snapshot' | 'live'
  onError(err) {
    showOffline(err);
  },
});

sub.unsubscribe();

Channels are the only concept — the realtime layer knows nothing about orders; /orders/{id} is a naming convention the service's namespace policy enforces.

The SDK owns the AppSync Events wire protocol, the keepalive watchdog and dead-connection detection, reconnect with jittered backoff, resubscribing every channel, token refresh via getToken, and ULID-based ordering/dedupe. One socket multiplexes all subscriptions.

No manual resync. The stream cannot replay missed events, so on every subscribe and reconnect the SDK opens the subscription first, then fetches the channel's last value from GET /v1/realtime/latest, and emits whichever is newer through the same onUpdate. Subscribing first means nothing falls between the snapshot and the stream. Pass getSnapshot per-subscribe to override where that catch-up value comes from.

Business client

const biz = await client.business.get(123);
// { id: 123, parent_id: 42, profile: { name: 'Acme' } }

const detailed = await client.business.get(123, {
  groups: ['profile', 'gst', 'payment_gateway'],
});

const many = await client.business.bulk([123, 124], { groups: ['profile'] });
  • get() returns the record or throws BusinessApiError (with .status and .body).
  • bulk() returns matched records in input order; missing ids are dropped silently.
  • The profile group (just name) is public; other groups need matching business.<group>:read capability for the calling service id.

Wallet client

getWallet(...) is a lightweight handle — no I/O. The wallet is resolved server-side on the first operation, so a routing miss surfaces from the operation, not from getWallet().

Which billing model? — start here

Prepaid wallets and merchant credit lines coexist, and which one a merchant is on decides what a dashboard renders and which calls are even legal. getOverview() settles it with a discriminated union:

const o = await wallet.getOverview();

if (o.mode === 'credit') {
  o.creditLine.limit; // { amount, amountMinor } — net of GST
  o.creditLine.band; // 'normal' | 'warning' | 'critical' | 'blocked'
} else {
  o.balance.balanceMinor; // prepaid: the wallet balance
}
  • balance is on both arms and means the same thing either way: what the merchant can still spend. Available credit on the credit arm.
  • One request for a prepaid merchant, two for a credit one, and never a 404 on the happy path — it reads getBalance().source rather than calling getCreditLine() and catching its 404, which would make the normal state of a prepaid merchant an error on every page load.
  • A wallet service older than the credit line (no source) reads as prepaid rather than throwing.
  • The credit arm needs wallet.credit:read on top of wallet.balance:read. A token with only the latter gets a WalletApiError with status === 403 from the second call — not a fallback to prepaid, which would render the frozen wallet_balance these merchants no longer spend from.
  • Not a guaranteed 200: WalletNotFoundError (no wallet document for the resolved identity) and UnresolvableWalletError (routing cannot name one) both propagate, because both mean there is nothing to render.

Credit line

The ceiling is per merchant, not per wallet, so an outlet's id and its parent's id return the same line.

const line = await wallet.getCreditLine(); // 404 CreditLineNotFoundError if prepaid
line.consumed; //  net, UNPAID — spans months, not a calendar figure
line.available; //  limit − consumed
line.estimatedInvoice.total; //  exact, from recorded GST — not consumed × 1.18

await wallet.setCreditLimit({
  referenceId: 'admin:limit:1200:2026-09-02', // idempotency key
  limitMinor: 10_000_000, // ₹1,00,000, NET of GST; 0 blocks
  onBehalfOf: 'user:4471', // required — the trail names the human
  reason: 'Q3 volume increase',
  // enabled: false → take them off the credit line
});

await wallet.settleInvoice({
  invoiceRef: 'invoice:2026-04:88213', // idempotency key
  netMinor: 500_000, // the NET figure — what frees credit
  gstMinor: 90_000, // recorded, not released
  onBehalfOf: 'user:9',
});
await wallet.reverseSettlement({ invoiceRef: '…', onBehalfOf: 'user:9' });
// Correcting a settled amount: reverse, then settle again with the right
// figures AND a `reason` — different figures on a reversed invoice
// release against the merchant's whole outstanding consumption, so the
// service requires the correction to say what it is correcting.

await wallet.listCreditEvents(); // limit changes + settlements, newest first
await wallet.listCreditEvents({ limit: 200 }); // the service defaults to 50
await client.wallet.getCreditUtilisation({ band: 'warning' }); // cross-merchant
  • Charges on a credit-line merchant must carry breakup — consumption is tracked net of GST, so a missing split is CreditBreakupRequiredError rather than a guessed rate. allowNegative is refused (CreditOverrideRefusedError): it disables the balance guard for migration paths, and on a credit line it would disable the limit.
  • The merchant pays gross; settlement releases net. A ₹5,900 invoice on ₹5,000 of consumption frees ₹5,000. Passing gross hands back 18% too much — and it looks right in a test, since both figures sit on the same invoice row.
  • Setting a limit never touches consumption, so raising one hands back exactly the difference. Lowering below what is consumed blocks charges immediately.
  • enabled: false is refused while the merchant owes — CreditLineHasOutstandingError, carrying consumedMinor so you can say how much to settle. Disabling moves them back to a wallet balance, which with a balance owed abandons it. To stop a merchant trading use limitMinor: 0; to move them off, settle first.
  • Reusing an idempotency key with different figures is CreditIdempotencyConflictError, not a silent replay. To correct a settled amount, reverse it and settle again rather than inventing a new ref — a new ref leaves the original settlement standing.
  • A plain credit() is refused — CreditTopUpRefusedError. These merchants have no balance to top up: wallet_balance is the fiction the line replaces and is frozen, so the write would move nothing while answering 201. Credit comes back through settleInvoice. A genuine refund is accepted — send reversalOf or isRefund and it releases what the original charge consumed.
  • getCreditUtilisation hangs off the client, not a wallet handle: it is the one wallet call not scoped to a business.

Errors

All extend WalletApiError (.status, .body).

CreditLimitReachedError carries limitMinor / consumedMinor / availableMinor / requestedMinor, and that is the point of it — three situations share the code and need different merchant copy:

if (e instanceof CreditLimitReachedError) {
  if (e.availableMinor === undefined) {
    // No figures in the response (trimmed or proxied body) — generic
    // copy. Not the same as 0, which is the "used up" sentinel below.
  } else if (e.availableMinor > 0) {
    // Limit NOT used up — this one charge is larger than what is left.
    // A smaller order would go through; do not send them to their KAM.
  }
  // Otherwise the ceiling is reached — which can be unpaid invoices
  // rather than this month's spend. getCreditLine() tells you which.
}

The figures are optional for that reason: coercing an absent availableMinor to 0 would silently claim the limit is used up.

Also: CreditLineNotFoundError (404 — a prepaid merchant, not a failure), CreditBreakupRequiredError, CreditOverrideRefusedError, CreditTopUpRefusedError, CreditIdempotencyConflictError, CreditCorrectionReasonRequiredError (400 — a reversed invoice is being re-settled for different figures; pass reason), CreditCurrencyConflictError and CreditCurrencyMismatchError (409 — neither is caller-retryable; see their docblocks), SettlementNotFoundError, plus the pre-existing InsufficientBalanceError, OverRefundError, InvalidReversalError, IdempotencyConflictError, WalletIdentityMismatchError, UnresolvableWalletError, WalletNotFoundError.

Money

Every figure is { amount, amountMinor }. Compute in amountMinor (integer paise); amount is for display. On a ledger row amount is gross and breakup.subTotalMinor is what consumed the ceiling — creditMerchantId present means the row's balanceBefore/balanceAfter are available credit rather than a wallet balance.

Vault client (credentials and KYC documents)

Encrypted per-brand payment-gateway / POS credentials and KYC documents. The same API works on a backend and in a browser; only where the token comes from differs. Service guide: services/vault/README.md.

Backend: client credentials

const platform = createClient({ serviceId, serviceSecret });
const vault = platform.vault.forTenant({ parentId: '38112', businessId: '7175' });

const meta = await vault.credentials.create({
  type: 'pg.razorpay',
  value: { keyId: 'rzp_live_x', keySecret: '...' },
  referenceId: 'razorpay-main',
  tags: { env: 'live' },
});
meta.masked; // masked values only, e.g. { keyId: 'rzp_*******ab12', keySecret: '********' }

const rzp = await vault.credentials.decrypt({ referenceId: 'razorpay-main' });
razorpay.init({ key_id: rzp.value.keyId.reveal(), key_secret: rzp.value.keySecret.reveal() });

forTenant mints a vault token (grant urn:uengage:params:oauth:grant-type:vault-business, every vault scope the client is allowed, the auth service's default 300 s lifetime), caches it per tenant until 60 s before expiry, shares one mint between concurrent calls, and re-mints once on a 401. Pass scopes / expiresIn to forTenant to narrow it. A brand handle ({parentId}) reaches every outlet; select one per call with businessId. The client needs vault.tokens:issue plus the vault scopes in its registry allowedScopes.

platform.vault.types.{register, list} work without forTenant: those endpoints also accept the ordinary service token with vault.types:*.

Frontend: a token your backend issued

// Backend route, after checking the user may act for this outlet:
import { auth } from '@uengage.io/platform-sdk';
const { token, expiresIn, scope } = await auth.mintVaultToken({
  clientId,
  clientSecret,
  parentId: 38112,
  businessId: 7175, // omit for a brand-wide token
  scopes: ['vault.pg:read', 'vault.kyc:write'], // omit for every allowed vault scope
  expiresIn: 900, // 60-14400 s, 300 by default
  onBehalfOf: `user-${user.id}`,
  authBaseUrl,
});

// Browser: the ./vault subpath is browser-safe (the root entry is not).
import { createVaultClient } from '@uengage.io/platform-sdk/vault';
const vault = createVaultClient({ env: 'prod', token, getToken: fetchVaultToken });
const page = await vault.credentials.list({ class: 'pg' }); // masked

The tenant comes from the token. forTenant(...) is optional in this mode and only checks the tenant against the token's (decoded, not verified) claim, throwing ConfigError on a mismatch. getToken may return the token or { accessToken, expiresIn }; it is called when the token has expired or got a 401, and the request is retried once. createClient({ authToken, getToken }) (or UENGAGE_AUTH_TOKEN) gives the same thing as platform.vault.

auth.mintVaultToken works like auth.mintUpsellToken and auth.mintRealtimeToken: backend only, Basic client credentials, never cached, and it rejects bad ids, non-vault scopes, an expiresIn outside 60–14400 s or an invalid onBehalfOf (1–128 printable ASCII characters) with ConfigError before sending. It returns { token, expiresIn, parentId, businessId?, scope } and throws AuthenticationError on a refused mint or a response that does not match the request. The client needs vault.tokens:issue. Never give a browser vault.<class>:decrypt: keep it out of the issuing client's allowedScopes.

Methods

| Namespace | Methods | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | credentials | create, get(id), getByReference(ref, {businessId?}), list(filter), listAll(filter), versions(id), rotate(id, value), disable(id), delete(id), setTags(id, tags), decrypt({id} \| {referenceId, businessId?}), decryptAll(filter) | | documents | upload({type, file, referenceId?, tags?, businessId?, contentType?, filename?}), get, getByReference, list, listAll, setTags, view(id, {reason?}) | | types | register({type, mask?, hiddenFields?}), list({class?}) |

Filters take type or class (one is required), tags (sent as tag.<key>=<value>, all must match), status (credentials active / disabled, documents pending / uploaded), businessId, includeOutlets (brand tokens), limit (≤ 100) and cursor. list returns { items, nextCursor? }; listAll and decryptAll are async iterators that follow nextCursor to the end:

for await (const cred of vault.credentials.decryptAll({ class: 'pg', tags: { env: 'live' } })) {
  configure(cred.referenceId, cred.value.keySecret.reveal());
}

documents.upload is one call: it registers the document (POST /documents, which returns a presigned S3 POST), sends the file straight to S3, then calls POST /documents/{id}/complete, and resolves with the DocumentMeta (status: 'uploaded'). file is a Blob/File, Uint8Array (so a Buffer) or ArrayBuffer; contentType defaults to file.type and filename to file.name. view returns a 5-minute URL for uploaded documents only (409 document_not_uploaded otherwise):

const doc = await vault.documents.upload({
  type: 'kyc.gst_certificate',
  file, // e.g. from <input type="file">
  referenceId: 'gst:2026',
});
const { url } = await vault.documents.view(doc.id, { reason: 'KYC review' });

Secrets

Every decrypted field is a SecretValue. .reveal() returns the plaintext; JSON.stringify, String(), template literals, console.log and util.inspect all print [REDACTED]. Call reveal() at the point of use, never log or persist its result, and do not copy it into other objects.

Errors

All extend VaultApiError (.status, .body, .code = the body's error): VaultDeniedError (403, every denial, deliberately without a reason), VaultNotFoundError (404), VaultConflictError (409: reference_conflict, version_conflict, credential_disabled, document_not_uploaded, upload_not_found, upload_mismatch), VaultValidationError (400/422: unknown_type), VaultUnavailableError (5xx, or status 0 on a network failure). A refused token mint throws AuthenticationError; bad input and a missing auth mode throw ConfigError.

Auth helpers

import { auth } from '@uengage.io/platform-sdk';
// or: client.auth.* — same namespace either way (stateless functions)

const pkce = auth.generatePKCE();
const url = auth.createAuthorizeUrl({
  authBaseUrl: 'https://api.platform.uengage.io/auth/business',
  clientId: 'my-app',
  redirectUri: 'https://my-app.example/cb',
  tenant: 'demo-tenant',
  codeChallenge: pkce.code_challenge,
  state: 'csrf-state',
});
// browser navigates to `url`; on callback, exchange the code:
const tokens = await auth.exchangeCode({
  authBaseUrl: 'https://api.platform.uengage.io/auth/business',
  code: 'returned-code',
  codeVerifier: pkce.code_verifier,
  clientId: 'my-app',
  redirectUri: 'https://my-app.example/cb',
});

auth exports stateless helpers; you can import the namespace directly or reach it via any client (client.auth). The same object is reused across clients because there's no state to isolate.

Build / test

pnpm --filter @uengage.io/platform-sdk build
pnpm --filter @uengage.io/platform-sdk test

Publish

The package publishes on a platform-sdk-vX.Y.Z tag push. To cut a release:

  1. Bump version in package.json.
  2. Open a PR; merge.
  3. Tag platform-sdk-vX.Y.Z on main and push — the publish workflow runs pnpm publish --filter @uengage.io/platform-sdk --access public.