@outerjoyn/sdk
v1.9.0
Published
OuterJoyn Node.js server SDK
Maintainers
Readme
@outerjoyn/sdk
Node.js server SDK for the OuterJoyn loyalty platform. Follows the Stripe SDK pattern: hand-written, typed, zero runtime dependencies.
Installation
npm install @outerjoyn/sdk
# or
yarn add @outerjoyn/sdkRequires Node.js 18+ (uses native fetch).
Quick Start
import OuterJoyn from '@outerjoyn/sdk';
const oj = new OuterJoyn('oj_test_your_api_key');
// 1. Confirm who your key is — business unit, scopes, test-vs-live realm.
// This is the natural first call for a new credential (GET /api/v1/me).
const me = await oj.me();
console.log(me.business_unit_id, me.is_test, me.scopes.effective);
// 2. Create a session for a member
const session = await oj.sessions.create({
partnerMemberId: 'user-abc-123',
partnershipId: 42,
identityHints: { email: '[email protected]' },
scopes: ['balance:read', 'exchange', 'redeem', 'earn'],
});
// 3. Check payable balances
const { balances } = await oj.pay.getBalances(session.token);
// 4. Reserve points at checkout
const payment = await oj.pay.checkout({
sessionToken: session.token,
merchantOrderId: 'order-789',
orderTotalCents: 2500,
currencyCode: 'USD',
reservations: [{
memberCurrencyId: balances[0].memberCurrencyId,
currencyId: balances[0].currencyId,
pointsAmount: 1500,
pointValueAmount: 1,
platformFeeCents: 30,
}],
idempotencyKey: 'idem-abc-123',
});
// 5. Confirm after merchant processes
await oj.pay.confirm({
sessionToken: session.token,
redemptionToken: payment.reservations[0].redemptionToken,
});Configuration
// Simple: just pass your API key
const oj = new OuterJoyn('oj_live_abc123');
// Full config
const oj = new OuterJoyn({
apiKey: 'oj_live_abc123',
baseUrl: 'https://api.outerjoyn.com', // default
timeout: 30000, // ms, default 30s
maxRetries: 2, // retries on 5xx for GET, default 2
});Use oj_test_* keys for sandbox and oj_live_* keys for production.
Resources
Introspection (oj.me())
The natural first call for any new key — GET /api/v1/me. Confirm your business
unit, effective scopes, and is_test realm before you build anything. The
response is built entirely from the calling credential's own context (a key only
ever sees itself); snowflake ids come back as strings.
const me = await oj.me();
// {
// auth_source: 'api_key',
// business_unit_id: '1589490538',
// is_test: true,
// scopes: { granted: ['members:*'], effective: ['members:read', 'members:write'] },
// api: { base_path: '/api/v1', docs_url: 'https://docs.outerjoyn.com/...' },
// surfaces: { canonical: '/api/v1', sdk: '/api/sdk', ... },
// }Sessions
// Create session
const session = await oj.sessions.create({
partnerMemberId: 'user-abc-123',
partnershipId: 42,
brandProgramId: 100,
identityHints: { email: '[email protected]', name: 'Jane Doe' },
scopes: ['balance:read', 'exchange', 'redeem', 'earn'],
ttlMinutes: 15,
});
// Revoke session
await oj.sessions.revoke({ token: session.token });
// Get session context
const ctx = await oj.sessions.getContext(session.token);Pay (Two-Phase Flow)
// Get balances
const { balances } = await oj.pay.getBalances(token);
// Checkout (reserve points)
const payment = await oj.pay.checkout({
sessionToken: token,
merchantOrderId: 'order-789',
orderTotalCents: 2500,
currencyCode: 'USD',
reservations: [{ memberCurrencyId: 1, currencyId: 10, pointsAmount: 1500, pointValueAmount: 1, platformFeeCents: 30 }],
refundPolicy: 'points_first',
idempotencyKey: 'idem-abc-123',
});
// Confirm
await oj.pay.confirm({ sessionToken: token, redemptionToken: 'ojrt_xYz789' });
// Cancel (release held points)
await oj.pay.cancel({ sessionToken: token, redemptionToken: 'ojrt_xYz789' });
// Refund
const refund = await oj.pay.refund({
sessionToken: token,
redemptionToken: 'ojrt_xYz789',
refundAmountCents: 500,
refundPolicy: 'points_first',
merchantRefundId: 'refund-456',
reason: 'Customer returned item',
});
// Get payment session
const ps = await oj.pay.getSession(5001, token);
// --- Order-grained verbs (API-key auth, no session token) ---
// Confirm every reservation on the order atomically, keyed by YOUR order id —
// no redemption-token bookkeeping. Use this from your server after the card
// charge succeeds.
await oj.pay.confirmAll({ merchantOrderId: 'order-789', idempotencyKey: 'confirm-order-789' });
// Release every held reservation (card failed / cart abandoned)
await oj.pay.cancelAll({ merchantOrderId: 'order-789' });
// Refund by order — for an OMS/CS tool/batch feed that knows the order, not
// the tokens. merchantRefundId is required and is the idempotency key.
await oj.pay.refundByOrder({
merchantOrderId: 'order-789',
amountCents: 500, // total across the order; omit to refund all refundable
merchantRefundId: 'RMA-1',
kind: 'return',
});Earn
// Submit earn event
const earn = await oj.earn.submit({
sessionToken: token,
merchantOrderId: 'order-789',
amount: 2500,
currencyCode: 'USD',
category: 'dining',
merchantName: 'Cafe Luna',
items: [{ name: 'Latte', quantity: 2, amountCents: 1200 }],
source: 'sdk_earn',
});
// Check earn status
const status = await oj.earn.getStatus(earn.transactionId, token);Balance
// Get member balance
const balance = await oj.balance.get(token);
// Get full account overview
const account = await oj.balance.getAccountOverview(token);Catalog
// Browse catalog
const catalog = await oj.catalog.list(token, {
page: 1,
limit: 20,
category: 'Gift Cards',
search: 'coffee',
maxPrice: 5000,
});
// Get product detail
const product = await oj.catalog.get(501, token);Redeem
// Redeem a reward
const redemption = await oj.redeem.create({
sessionToken: token,
productId: 501,
quantity: 1,
metadata: { campaign: 'spring-2026' },
});Exchange
// Get exchange rates
const rates = await oj.exchange.getRates(42);
// Preview exchange
const preview = await oj.exchange.preview({
sourceCurrencyId: 10,
targetCurrencyId: 20,
sourceAmount: 1000,
partnershipId: 42,
});
// Execute exchange
const result = await oj.exchange.execute({
sourceCurrencyId: 10,
targetCurrencyId: 20,
sourceAmount: 1000,
partnershipId: 42,
idempotencyKey: 'exch-abc-123',
});
// Get exchange status
const status = await oj.exchange.getStatus(result.transactionId);Members
// List members (API key auth)
const members = await oj.members.list({ page: 1, limit: 50, search: 'jane' });
// Get member detail
const member = await oj.members.get(100200);Promotions
// Get promotion array (public, no auth)
const array = await oj.promotions.getArray('550e8400-e29b-41d4-a716-446655440000');
// Get promotion detail
const promo = await oj.promotions.get('660e8400-e29b-41d4-a716-446655440001');
// Enroll member
const enrollment = await oj.promotions.enroll('660e8400-...', 100200);
// Check enrollment status
const status = await oj.promotions.getEnrollmentStatus('550e8400-...', 100200);Partnerships
The full B2B lifecycle. Write bodies use snake_case ids on the wire; the SDK takes ergonomic camelCase params and maps them for you.
Multi-brand BUs must pass
brandId. If your business unit owns more than one brand,proposeneeds your ownbrandIdto disambiguate the proposer — without it the server returns422 proposer_brand_ambiguous. Single-brand BUs may omit it (your brand resolves from the key). Find your brand ids viaoj.objects.list('brands').
// Propose a partnership toward another brand → { partnership_request_id, status: 'submitted' }
const proposal = await oj.partnerships.propose({
brandId: '43595573', // YOUR brand — REQUIRED on multi-brand BUs
partnerBrandId: '88070021', // the counterpart brand
useCase: 'pay_with_points',
});
// Or list yourself on the marketplace in one call (brandId = YOUR own brand)
const listing = await oj.partnerships.platform({ brandId: '43595573' });
// The counterpart accepts the proposal
await oj.partnerships.accept(proposal.partnership_request_id!);
// Owner activates a use case (runs the activation contract)
await oj.partnerships.activate('88070021', { useCase: 'pay_with_points' });
// Fund the pool — idempotencyKey is REQUIRED and sent as the canonical
// Idempotency-Key header, so a retry never double-funds.
const funded = await oj.partnerships.fund('88070021', {
points: 100_000,
currencyId: '77120034',
idempotencyKey: 'fund-2026-q3-001',
});
// Declare a rate for the currency pair (omit rate to derive from USD pegs)
await oj.partnerships.setRates('88070021', {
sourceCurrencyId: '77120034',
targetCurrencyId: '77120099',
rate: 0.5,
});
// Inspect readiness / list / enumerate linked members
const detail = await oj.partnerships.get('88070021');
const all = await oj.partnerships.list({ status: 'active' });
const linked = await oj.partnerships.members('88070021');Ingest
Async high-volume writes. Each accept returns 202 immediately; poll batch()
for progress. Bodies are arrays of camelCase records (max 10,000 per batch).
// Members — clientRef is the required dedup key
const accepted = await oj.ingest.members([
{ clientRef: 'crm-1001', email: '[email protected]', firstName: 'Ada' },
]);
// Transactions — dedup on idempotencyKey (or clientRef)
await oj.ingest.transactions([
{ idempotencyKey: 'txn-9001', memberRef: 'crm-1001', amount: 4200, points: 42 },
]);
// Poll status
const status = await oj.ingest.batch(accepted.batchId);Approvals
Decide approvals programmatically as a named approver. (List pending approvals via
the generic Approval object on /api/v1.)
await oj.approvals.approve('5001', {
approverUserId: '51230098',
reason: 'Terms reviewed and approved.',
});
await oj.approvals.reject('5001', { approverUserId: '51230098' });Integrations
API-first connections + import/export jobs (integrations:read/integrations:write).
Connection secrets have one write path (setConnectionSecret) and never come back
in plaintext.
Connection type is one of ftp, sftp, database, or s3_external (a
customer-owned S3-compatible bucket the streaming-export push sink delivers to).
// Register a connection, then set its secret (the only write path).
const { data: conn } = await oj.integrations.createConnection({
type: 'sftp', name: 'Nightly member drop', host: 'sftp.partner.example.com', port: 22,
});
await oj.integrations.setConnectionSecret(conn.id!, 's3cr3t-sftp-passphrase');
// …or an s3_external bucket. bucket/region/endpoint/prefix are non-secret; the
// access keys go through setConnectionSecret, same as any other connection.
const { data: s3 } = await oj.integrations.createConnection({
type: 's3_external', name: 'Prod export bucket',
bucket: 'acme-loyalty-exports', region: 'us-east-1', prefix: 'outerjoyn/',
});
await oj.integrations.setConnectionSecret(s3.id!, JSON.stringify({
access_key_id: 'AKIA…', secret_access_key: '…',
}));
// Create an import integration referencing the connection by id.
const { data: integ } = await oj.integrations.create({
name: 'Nightly member import',
direction: 'import',
dataSource: { type: 'sftp', ftp_location_id: conn.id, file_path: '/inbound/members/latest.csv' },
schedule: { frequency: 'daily', time_of_day: '02:00' },
});
await oj.integrations.run(integ.integration_id!);
const { data: runs } = await oj.integrations.runs(integ.integration_id!, { limit: 5 });Subscriptions
Unified event + CDC webhook subscriptions (webhooks:read/webhooks:write). The
signing secret is revealed once, on create/rotate.
const { data } = await oj.subscriptions.create({
kind: 'events',
eventTypes: ['member.created', 'points.earned'],
webhookUrl: 'https://hooks.partner.example.com/outerjoyn',
});
// Store data.signing_secret now — it's never returned again.
await oj.subscriptions.rotateSecret(data.subscription.id!);Exports
Stream any exportable object to a signed download URL — no row cap
(exports:read).
const { data: job } = await oj.exports.create({
object: 'member',
format: 'csv',
fields: ['member_id', 'first_name', 'last_name', 'status', 'created_at'],
});
// Poll until completed, then read the signed download URLs.
const { data: status } = await oj.exports.get(job.export_id);
if (status.status === 'completed') {
console.log(status.download?.manifest_url, status.download?.parts);
}Change feed (pull CDC): GET /api/v1/external/changes?object=&since= is the
cursor-paged "what changed since X" counterpart to the webhook subscriptions above
(changes:read scope). Field values are exposure-projected identically to reads
and exports. It has no dedicated SDK resource yet — call it with your own HTTP
client (or the fetch you already configured) against the base URL until a typed
oj.changes resource lands.
Composite batch: POST /api/v1/external/batch runs up to 20 sub-requests in
one round-trip (each item is independently scope-gated — a denied item 403s the
item, not the batch; depends_on + {result:…} substitution chains items). Also
no dedicated SDK resource yet — POST to it directly.
Tasks
The use-case task checklist that stands between an activated partnership and a
live one (partnerships:read / partnerships:write).
const { data: tasks } = await oj.tasks.list(useCaseId);
// Move a task forward, or complete it (completion gates run server-side).
await oj.tasks.setStatus(taskId, 'in_progress');
await oj.tasks.complete(taskId, { notes: 'DSA signed' });
// Attach an inbound-webhook integration to a task.
await oj.tasks.configureIntegration(taskId, {
webhookUrl: 'https://hooks.example.com/oj',
events: ['member.created'],
secret: 'a-shared-secret',
});
// Payment terms seeded for the use case's partnership.
const { data: terms } = await oj.tasks.listPaymentTerms(useCaseId);
await oj.tasks.updatePaymentTerm(terms[0].payment_term_id!, { isActive: true });Partnership config lives on the partnerships resource — tier/benefit mappings,
liability deals, and the identity match key (all owner-side writes):
await oj.partnerships.setMatchKey(partnershipId, 'email');
await oj.partnerships.createTierMapping(partnershipId, { sourceTierId: '1', targetTierId: '2' });
await oj.partnerships.createBenefitMapping(partnershipId, { sourceBenefitId: '10' });
await oj.partnerships.createLiabilityDeal(partnershipId, {
currencyId: '77120034',
targetCurrencyId: '77120099',
purchaseRatePercent: 70,
memberRatePercent: 100,
});Contracts
The contract link of the partnership spine (partnerships:read /
partnerships:write; create + transition are owner-only).
const { data: contracts } = await oj.contracts.list(partnershipId);
const { data: contract } = await oj.contracts.create(partnershipId, { name: 'MSA' });
await oj.contracts.transitionStatus(contract.contract_id, 'active');API Keys
Self-service key management (api_keys:read / api_keys:write). A live key may
only mint scopes it already holds; the raw secret is returned once.
const { data } = await oj.apiKeys.create({
name: 'CI key',
scopes: ['members:read', 'transactions:write'],
});
console.log(data.key); // store now — never retrievable again
await oj.apiKeys.rotate(data.id);
await oj.apiKeys.revoke(data.id);
// list() returns { data: ApiKeySummary[], has_more?, total? } — the array is
// `data` itself (NOT `data.keys`). So iterate `res.data`, not `res.data.keys`.
const res = await oj.apiKeys.list({ limit: 50 });
console.log(res.data.length, res.total);
const ci = res.data.find((k) => k.name === 'CI key');Objects (describe + generic escape hatch)
The self-describing catalog + a generic read/write path for any exposable object
that has no designed resource. The designed resources (oj.members,
oj.partnerships, …) stay the first-class path.
const { objects } = await oj.objects.listObjects();
const schema = await oj.objects.describe('members');
// Long-tail CRUD (keyset paginated):
const { data, next_cursor } = await oj.objects.list('brands', {
limit: 50,
fields: ['brand_id', 'name'],
filters: { status: 'Active' },
});
const one = await oj.objects.get('brands', '43595573');
await oj.objects.create('brands', { name: 'Acme' }); // writable objects onlyAccounts
Corporate account structure — hierarchy + membership (crm:read / crm:write).
(List/get the account rows themselves via oj.objects.list('accounts').)
await oj.accounts.setParent(childId, parentId);
const { ancestors, descendants } = await oj.accounts.hierarchy(accountId);
await oj.accounts.enrollMembership(accountId); // idempotent
await oj.accounts.assignContact(contactId, accountId);Fraud Rules
Fraud rule CRUD (fraud:read / fraud:write). Scope with brandIds only;
config.preset is reserved (preset rules are read-only).
const { data } = await oj.fraudRules.create({
ruleName: 'Accrual velocity guard',
ruleType: 'velocity_accrual',
thresholdCount: 5,
timeWindowMinutes: 60,
action: 'flag',
severity: 'high',
brandIds: ['43595573'], // [] = whole business unit
});
await oj.fraudRules.update(data.fraud_rule_id, { isEnabled: false });Contacts
Single-BU contact merge (crm:write). The path id is the winner (kept);
sourceContactId is the loser (removed). Idempotent on replay.
await oj.contacts.merge(winnerContactId, { sourceContactId: loserContactId });Zero to live: the partnership journey
The six headline calls that take a brand from signup to a live, funded partnership — the same flow as the ZERO-TO-LIVE guide, now expressible entirely in SDK calls:
import OuterJoyn from '@outerjoyn/sdk';
const oj = new OuterJoyn('oj_test_your_api_key');
// 1. Propose (or list on the marketplace in one call).
// On a multi-brand BU, pass your own `brandId` too (else 422
// proposer_brand_ambiguous); single-brand BUs may omit it.
const { partnership_request_id } = await oj.partnerships.propose({
brandId: '43595573', // YOUR brand — required on multi-brand BUs
partnerBrandId: '88070021',
useCase: 'pay_with_points',
});
// 2. Accept (the counterpart side) → the shared Partnership is created
const { partnership_id } = await oj.partnerships.accept(partnership_request_id!);
// 3. Activate the use case
await oj.partnerships.activate(partnership_id!, { useCase: 'pay_with_points' });
// 4. Fund the pool (idempotent by header)
await oj.partnerships.fund(partnership_id!, {
points: 100_000,
currencyId: '77120034',
idempotencyKey: 'fund-2026-q3-001',
});
// 5. Set the exchange rate
await oj.partnerships.setRates(partnership_id!, {
sourceCurrencyId: '77120034',
targetCurrencyId: '77120099',
rate: 0.5,
});
// 6. Confirm readiness — you're live
const detail = await oj.partnerships.get(partnership_id!);Webhook Verification
The platform signs every outbound webhook with HMAC-SHA256 over the raw request
body and sends the hex digest in a sha256=<hex>-prefixed header — no
timestamp:
| Delivery | Header |
| --- | --- |
| Event subscriptions | X-OuterJoyn-Signature: sha256=<hmac(rawBody)> |
| CDC / change feeds | X-CDC-Signature: sha256=<hmac(rawBody)> |
constructEvent / verifySignature accept either header value. The secret is
the signing_secret returned once from subscriptions.create /
rotateSecret (an opaque token — it has no whsec_ prefix on this platform).
Verify the raw bytes. Pass the body exactly as received (
express.raw) — neverJSON.stringify(req.body), because re-serializing can reorder keys and break the HMAC.
Express Middleware
import express from 'express';
import OuterJoyn from '@outerjoyn/sdk';
const oj = new OuterJoyn('oj_live_abc123');
const app = express();
app.post(
'/webhooks/outerjoyn',
express.raw({ type: 'application/json' }), // req.body is a Buffer of the raw bytes
(req, res) => {
// Event deliveries use X-OuterJoyn-Signature; CDC deliveries use X-CDC-Signature.
const sig = (req.headers['x-outerjoyn-signature'] ||
req.headers['x-cdc-signature']) as string;
let event;
try {
event = oj.webhooks.constructEvent(req.body, sig, signingSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
switch (event.type) {
case 'transaction.created':
console.log('Transaction:', event.data);
break;
case 'member.updated':
console.log('Member updated:', event.data);
break;
}
res.json({ received: true });
},
);Test Helper
// Generate a signature header in the platform's wire format (sha256=<hmac>).
const payload = JSON.stringify({ id: 'evt_123', type: 'test', data: {}, createdAt: new Date().toISOString() });
const header = oj.webhooks.generateTestHeader(payload, signingSecret);
const event = oj.webhooks.constructEvent(payload, header, signingSecret);Error Handling
All errors extend OuterJoynError with typed subclasses:
import OuterJoyn, {
OuterJoynError,
OuterJoynAuthenticationError,
OuterJoynRateLimitError,
OuterJoynInvalidRequestError,
OuterJoynAPIError,
} from '@outerjoyn/sdk';
const oj = new OuterJoyn('oj_test_abc123');
try {
await oj.pay.checkout({ /* ... */ });
} catch (err) {
if (err instanceof OuterJoynAuthenticationError) {
// 401/403 - bad API key or expired session
console.error('Auth failed:', err.message);
} else if (err instanceof OuterJoynRateLimitError) {
// 429 - back off and retry
console.error('Rate limited, retry after:', err.retryAfter, 'seconds');
} else if (err instanceof OuterJoynInvalidRequestError) {
// 400/422 - bad input
console.error('Invalid request:', err.message, err.code, err.param);
} else if (err instanceof OuterJoynAPIError) {
// 5xx - server error
console.error('Server error:', err.message);
} else if (err instanceof OuterJoynError) {
// Catch-all
console.error('OuterJoyn error:', err.statusCode, err.message);
}
}Error properties:
| Property | Type | Description |
|----------|------|-------------|
| statusCode | number | HTTP status code |
| type | string | Error type (invalid_request_error, authentication_error, rate_limit_error, api_error) |
| code | string? | Machine-readable error code |
| param | string? | Parameter that caused the error |
| requestId | string? | Request ID for support |
| retryAfter | number? | Seconds until rate limit resets (429 only) |
API Version
The SDK sends X-API-Version: 2026-03-29 on all requests. This pins your integration to a specific API version.
License
MIT
