@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
Maintainers
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.envmutated 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 shutdownrecord()is fire-and-forget. It generatesevent_id(ULID) andoccurred_at, fillsactor.viawith the configuredactorVia(orserviceId), 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
actorViais 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 eventsimport { 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, anddomain(mapped from thetypeprefix — an explicit map, not a pluralisation rule, sincemenu,loyaltyandusageare not plural). type/domain/sourceare also set as SNS message attributes, which is what consumer filter policies match on.- Config is env-first:
PLATFORM_EVENTS_TOPIC_ARN, optionallyPLATFORM_EVENTS_SOURCE(defaults toSERVICE_NAME). Region is read from the topic ARN. publish()throws —ConfigError,ValidationError, orEventPublishError(which carriesfailedEventIds). 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 aValidationErrornaming 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), derivescategoryfromtype(ALERT_TYPES— callers never send it), mintsid(a monotonic ULID) andoccurredAt(UTC, seconds precision), and sends the resultingAlertMessageas JSON straight to the alerts FIFO queue withMessageGroupId=alertMessageGroupId(msg)(parentId#businessId#type) andMessageDeduplicationId=msg.id.raise()resolves with the message it sent. - Tenant ids may be strings or positive integers; integers are stringified, so
7175and"7175"are the same alert. contextis flat: string / finite number / boolean values, at most 50 keys.- Adding an alert type means adding it to
ALERT_TYPESinalerts/schema.tsand releasing the SDK. - Queue URL, first match wins: the
queueUrloption →PLATFORM_ALERTS_QUEUE_URL→ the platform default for theenvoption orUENGAGE_ENV(uat|prod, seeALERT_QUEUE_URLS) →ConfigError. The region comes from theregionoption, else the queue URL's host, elseAWS_REGION/AWS_DEFAULT_REGION. alertsis a lazy singleton configured from the environment.createAlertsPublisher({ queueUrl, env, region, sqsClient })builds an isolated one (sqsClientfor tests or custom credentials).raise()throws:ValidationError(bad input — nothing is sent),ConfigError(no queue URL),AlertPublishError(SQS failed; carriesalertIdandcause). 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:SendMessageonalerts-{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 ofbusinessId,parentId,type,category,status,userId(alerts that dashboard user can see; seeAlert.userIds) ornotify: true(only alerts whose latest event hadnotifyon);status,limit(1–100, default 25) andcursornarrow further. Results are ordered bylastSeenAt, newest first.setStatus()requirescommentforresolvedandmuted.open↔mutedandopen|muted→resolvedalways work. Reopening aresolvedalert (status: 'open') works only while no other open or muted alert exists for the same (businessId,parentId,type); otherwise the API answers 409 andsetStatus()throwsAlertsApiErrorwith.status409 and body errornewer_alert_open. A reopen setsreopenedAt/reopenedBy(andreopenComment) and clears theresolved*fields. Transitions are enforced server-side.- Each alert carries
count,firstSeenAt,lastSeenAtandupdatedAt. - Scopes:
alerts:readforlist/get,alerts:writeforsetStatus. - Non-2xx responses throw
AlertsApiErrorwith.statusand.body. Obviously bad arguments (no filter, empty id, missing comment) throw a plainErrorbefore 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.
listalways returns alerts whoseuserIdsinclude the token's user (anyuserIdis replaced server-side);getandsetStatusanswer 404 for any other alert. - Status changes are recorded as the token's user, so there is no
actor. getTokenis 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.mintAlertTokenthrowsConfigErroron bad input before sending, andAuthenticationErrorwhen 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 throwsBusinessApiError(with.statusand.body).bulk()returns matched records in input order; missing ids are dropped silently.- The
profilegroup (justname) is public; other groups need matchingbusiness.<group>:readcapability 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
}balanceis 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().sourcerather than callinggetCreditLine()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:readon top ofwallet.balance:read. A token with only the latter gets aWalletApiErrorwithstatus === 403from the second call — not a fallback toprepaid, which would render the frozenwallet_balancethese merchants no longer spend from. - Not a guaranteed 200:
WalletNotFoundError(no wallet document for the resolved identity) andUnresolvableWalletError(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 isCreditBreakupRequiredErrorrather than a guessed rate.allowNegativeis 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: falseis refused while the merchant owes —CreditLineHasOutstandingError, carryingconsumedMinorso 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 uselimitMinor: 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_balanceis the fiction the line replaces and is frozen, so the write would move nothing while answering 201. Credit comes back throughsettleInvoice. A genuine refund is accepted — sendreversalOforisRefundand it releases what the original charge consumed. getCreditUtilisationhangs 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' }); // maskedThe 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 testPublish
The package publishes on a platform-sdk-vX.Y.Z tag push. To cut a release:
- Bump
versioninpackage.json. - Open a PR; merge.
- Tag
platform-sdk-vX.Y.Zonmainand push — the publish workflow runspnpm publish --filter @uengage.io/platform-sdk --access public.
