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

@outerjoyn/sdk

v1.9.0

Published

OuterJoyn Node.js server SDK

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/sdk

Requires 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, propose needs your own brandId to disambiguate the proposer — without it the server returns 422 proposer_brand_ambiguous. Single-brand BUs may omit it (your brand resolves from the key). Find your brand ids via oj.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 only

Accounts

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) — never JSON.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