@pulsebyshiga/node
v0.2.1
Published
Pulse Collect backend SDK — collection sessions, orders, and webhook signature verification
Readme
@pulsebyshiga/node
The official Node.js SDK for the Pulse API — quotes, offramp and onramp orders, bank name-enquiry, collection sessions, and webhook verification. Idempotent requests, automatic retries, and fully-typed responses and errors are built in.
Server-side only: your sk_* key and user identity (BVN/NIN) never leave your backend. The
browser and mobile SDKs receive only a short-lived, single-order session token (cs_*).
Pre-release (0.2.0). The public API may still change. Quotes, offramp/onramp orders, and bank name-enquiry map 1:1 to the live engine contract (Swagger).
Requirements
- Node.js ≥ 18.17
- ESM (
import) — this package does not ship a CommonJS build
Installation
npm install @pulsebyshiga/node
# pnpm add @pulsebyshiga/node · yarn add @pulsebyshiga/nodeQuickstart
import Pulse, { useApiKey } from '@pulsebyshiga/node';
const pulse = new Pulse(useApiKey(process.env.PULSE_API_KEY), {
webhookSecret: process.env.PULSE_WEBHOOK_SECRET,
});
// Lock a price, then create an order against it.
const quote = await pulse.quotes.create({
amount: '100.50',
source_currency: 'USDC',
destination_currency: 'NGN',
network: 'BASE',
});useApiKey(...) declares the server credential (a bare string still works for back-compat).
The key is sent as X-API-Key (the engine's scheme) and as a Bearer token (the session
surface's scheme) — one key, both means. The default host is https://engine-api.shiga.io;
override it with baseUrl for local development or a future sandbox.
Core concept — quote first
Everything starts with a locked price. Create a quote, then create an order from quote.id
before expires_at passes. Amounts are decimal strings; networks are UPPERCASE.
Just the rate, no order — rates.preview wraps a quote for display; nothing settles and no
order is created:
const rate = await pulse.rates.preview({
from: 'USDC',
to: 'NGN',
amount: '100.00',
network: 'BASE',
});
// → { rate, youSend, youReceive, expiresAt, quoteId }
// Proceed to an order with quoteId to transact at exactly this locked rate.Offramp — crypto in, fiat out
The collection direction. Verify the payout account (name enquiry), then create the order:
const quote = await pulse.quotes.create({
amount: '100.50',
source_currency: 'USDC',
destination_currency: 'NGN',
network: 'BASE', // BASE | POLYGON | ARBITRUM | OPTIMISM | ETHEREUM | BSC
});
const account = await pulse.banks.resolve({ account_number: '0123456789', bank_code: '000013' });
const order = await pulse.offramp.create({
quote_id: quote.id,
customer_id: 'your_user_123',
customer_name: account.account_name,
customer_email: '[email protected]',
account_number: account.account_number,
account_name: account.account_name,
bank_code: account.bank_code,
bank_name: account.bank_name,
});
// Show order.deposit_address — the user sends quote.source_amount there.
// Poll pulse.offramp.retrieve(order.id) until a terminal status.Onramp — fiat in, crypto out
const quote = await pulse.quotes.create({
amount: '50000.00',
source_currency: 'NGN',
destination_currency: 'USDC',
network: 'BASE',
});
const order = await pulse.onramp.create({
quote_id: quote.id,
customer_id: 'your_user_123',
customer_name: 'Ada Lovelace',
customer_email: '[email protected]',
destination_address: '0xUserWallet…',
});
// order.account is the virtual bank account to pay (amount + expires_at).
// Poll pulse.onramp.retrieve(order.id) until a terminal status.Banks
pulse.banks.list()— supported banks (name + NIP code).pulse.banks.resolve({ account_number, bank_code })— name enquiry; run it before creating an offramp order.
Collection sessions
The surface the embedded components run on. Create a session server-side and hand the cs_*
token to your frontend — nothing else.
// Naira gate — funds settle to the user's named virtual account.
// First use provisions the account from BVN/NIN; returning users pass the reference.
const session = await pulse.collectionSessions.create(
{
gate: 'naira',
target: { currency: 'NGN', amount: '1000000.00' },
asset: 'USDT',
network: 'solana',
user_ref: 'your_user_123',
account: { provision: true, bvn: '...', nin: '...' },
},
{ idempotencyKey: `fund-${depositId}` },
);
// USD gate — funds settle to your omnibus account; no per-user identity.
const usdSession = await pulse.collectionSessions.create({
gate: 'usd',
target: { currency: 'USD', amount: '500.00' },
asset: 'USDC',
network: 'base',
});
// Hand session.session_token (cs_*) to your frontend. Nothing else.Direction. Sessions default to offramp (crypto in → fiat out). Pass direction: 'onramp'
for the reverse — the user pays a virtual bank account (carried on the order as pay_account)
and receives crypto at a wallet. The embedded component renders the matching UI automatically
(bank-transfer instructions instead of a deposit address/QR).
const onrampSession = await pulse.collectionSessions.create({
gate: 'naira',
direction: 'onramp',
target: { currency: 'NGN', amount: '50000.00' },
asset: 'USDT',
network: 'base',
user_ref: 'your_user_123',
account: { provision: false, virtual_account_ref: 'va_...' },
});Query orders.
const order = await pulse.collectionOrders.retrieve(orderId); // ground truth for one order
const page = await pulse.collectionOrders.list({ gate: 'naira', status: 'completed', limit: 50 });
const relocked = await pulse.collectionOrders.refreshQuote(orderId); // new quote window for an expired orderWebhooks
Deliveries are signed with a Pulse-Signature header:
Pulse-Signature: t=<unix-seconds>,v1=<hex hmac-sha256 of "<t>.<raw body>">Verification requires the raw request body — do not parse JSON first.
import express from 'express';
app.post('/webhooks/pulse', express.raw({ type: '*/*' }), async (req, res) => {
const event = await pulse.webhooks.verifyOnce(req.body, req.header('pulse-signature') ?? '');
if (event === null) return res.sendStatus(200); // redelivery — already processed
switch (event.type) {
case 'disbursement.completed':
await creditPosition(event.data.order_id, event.data.disbursement.settlement_ref);
break;
case 'collection.amount.underpaid':
await handleShortfall(event.data); // { expected, received, asset, network, tx_hash }
break;
}
res.sendStatus(200);
});| Method | Behavior |
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| webhooks.verify(payload, header, secret?, options?) | Constant-time HMAC check + replay-window check (default tolerance 300 s). Returns the typed event or throws SignatureVerificationError. |
| webhooks.verifyOnce(payload, header, secret?, options?) | Everything verify does, plus de-duplication on the event id. Returns null for a redelivery. |
Delivery is at-least-once: handlers must tolerate duplicates. verifyOnce de-duplicates
in-process by default; multi-instance deployments must back it with a shared store:
import Pulse, { type ProcessedEventStore } from '@pulsebyshiga/node';
const store: ProcessedEventStore = {
has: (id) => redis.exists(`pulse:evt:${id}`).then(Boolean),
add: (id) => redis.set(`pulse:evt:${id}`, '1', 'EX', 86_400).then(() => undefined),
};
const pulse = new Pulse(apiKey, { webhookSecret, webhookEventStore: store });Event types
| Event | Fires when |
| ------------------------------------------ | -------------------------------------------------------------------------------------------- |
| collection.order.created | Session created; the full order is attached. |
| collection.deposit.detected | On-chain deposit seen, not yet confirmed. |
| collection.deposit.confirmed | Deposit confirmed at the required depth. |
| collection.deposit.wrong_chain | Funds arrived on a network other than the order's; the order stays payable on the right one. |
| collection.amount.underpaid / overpaid | Confirmed amount differs from the quote; carries expected vs received. |
| disbursement.completed | Fiat settled. The only event that credits a user. |
| collection.order.expired | Quote window elapsed without payment. |
| collection.order.failed | Terminal failure; carries a reason. |
Credit positions from disbursement.completed only. Client-side callbacks such as the
embed's onSuccess are UX signals and can be spoofed; the signed webhook cannot.
Reliability
- Idempotency — every POST carries an
Idempotency-Key(yours viaidempotencyKey, otherwise a generated UUID), so retries can never double-create an order. - Retries — network errors, HTTP 429, and 5xx are retried with exponential backoff (2 retries, 250 ms base delay by default). Other 4xx responses throw immediately.
- Identification, not telemetry — every request carries a static
X-Pulse-SDK: pulse-node/<version> node/<version> (<platform>)header so the API can measure adoption and error rates per SDK version. The SDK never emits telemetry from your infrastructure.
Configuration
new Pulse(apiKey, options?)| Option | Type | Default | Description |
| ------------------- | --------------------- | ----------------------- | ------------------------------------------------------------------- |
| baseUrl | string | derived from key prefix | API host override (local development, staging). |
| maxRetries | number | 2 | Retry count for network errors / 429 / 5xx. |
| retryDelayMs | number | 250 | Base backoff delay, doubled per attempt. |
| fetchImpl | FetchLike | global fetch | Injectable transport for tests/instrumentation. |
| webhookSecret | string | — | Default secret for webhooks.verify*; can also be passed per call. |
| webhookEventStore | ProcessedEventStore | in-memory | Shared de-duplication store for verifyOnce. |
Error handling
All errors extend PulseError:
| Class | Thrown when | Notable fields |
| ---------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------- |
| PulseApiError | API returned a non-retryable error, or retries were exhausted | status, code (e.g. network_not_enabled), requestId |
| PulseConnectionError | Request never produced an HTTP response | — |
| SignatureVerificationError | Webhook signature invalid, malformed, or outside the replay window | — |
import { PulseApiError } from '@pulsebyshiga/node';
try {
await pulse.collectionSessions.create(params);
} catch (error) {
if (error instanceof PulseApiError && error.code === 'network_not_enabled') {
// asset/network is disabled in your partner configuration
} else {
throw error;
}
}TypeScript
Written in TypeScript; every request, response, webhook event, and error is fully typed and
exported (CollectionOrder, WebhookEvent, PartnerConfig, Network, …). The webhook event
union discriminates on type, so a switch narrows event.data automatically.
Security model
sk_*keys and BVN/NIN are server-to-server only — never ship them to a browser or mobile app. Clients receive only the short-lived, single-ordersession_token(cs_*).- Verify every webhook before acting on it; verification is constant-time and replay-bounded.
- Credit users exclusively from the signed
disbursement.completedevent.
Related packages
@pulsebyshiga/collect-js— framework-agnostic web loader for the payment embed.@pulsebyshiga/collect-react— React bindings.@pulsebyshiga/collect-react-native— React Native bindings.
License
MIT © Shiga Digital
