Package Exports
- @opensettle/sdk
- @opensettle/sdk/errors
- @opensettle/sdk/webhooks
Readme
@opensettle/sdk
Official Node SDK for the OpenSettle API. Stablecoin billing on Base, Ethereum, Polygon, and Arbitrum.
Status: 0.1.x — initial release. API surface is stable; we'll bump to 1.0.0 once it's been used in real merchant integrations for a quarter without breaking changes. Source at github.com/OpenSettle/opensettle-sdk-js; for urgent issues email opensettle@proton.me.
Install
npm install @opensettle/sdk
# or
pnpm add @opensettle/sdk
# or
yarn add @opensettle/sdkRequires Node 20+ (uses built-in fetch and node:crypto).
Quickstart
import { OpenSettle } from "@opensettle/sdk";
const os = new OpenSettle({
apiKey: process.env.OPENSETTLE_KEY!, // sk_live_… or sk_test_…
workspaceId: process.env.OPENSETTLE_WORKSPACE!,
});
// Create a customer
const customer = await os.customers.create({
email: "ada@example.com",
name: "Ada Lovelace",
});
// Bill them with a one-shot invoice paid in USDC on Base.
// Total is derived from `lineItems[].unitAmountMinor * quantity` —
// there is no top-level `amountMinor`. `currency` defaults to "USD".
const invoice = await os.invoices.create({
customerId: customer.id,
chain: "base",
token: "USDC",
lineItems: [
{ description: "Pro plan", quantity: 1, unitAmountMinor: 19_900 }, // $199.00 — minor units (cents)
],
dueInDays: 14, // optional, default 14
});
await os.invoices.send(invoice.id); // emails the customer the linkConfiguration
new OpenSettle({
apiKey: "sk_live_…", // required
workspaceId: "ws_01HG…", // required
baseUrl: "https://api.opensettle.io", // optional override
testMode: process.env.NODE_ENV !== "production", // refuses sk_live_ when true
timeoutMs: 30_000, // per-request timeout
maxNetworkRetries: 3, // retries on 5xx + 429 + network errors
fetch: customFetch, // override the global fetch (rare)
});The SDK refuses to send live traffic on a test key (and vice versa) when
testMode is set explicitly — useful as a CI circuit breaker.
Errors
Every non-2xx response throws a typed subclass of OpenSettleError. Catchers
can branch on either the class or the stable code property.
import {
OpenSettleError,
RateLimitError,
SettlementError,
StepUpRequiredError,
} from "@opensettle/sdk";
try {
await os.payments.refund("pay_1", { amountMinor: 100 });
} catch (err) {
if (err instanceof RateLimitError) {
await sleep((err.retryAfter ?? 1) * 1000);
return retry();
}
if (err instanceof StepUpRequiredError) {
return promptUserToReauthenticate(); // refunds need AAL=2
}
if (err instanceof SettlementError && err.code === "insufficient_confirmations") {
return tryAgainInAFewBlocks();
}
if (err instanceof OpenSettleError) {
log.error({ code: err.code, requestId: err.requestId }, err.message);
}
throw err;
}| Class | HTTP | Error code(s) |
|---|---|---|
InvalidRequestError |
400 | invalid_request |
InvalidStateTransitionError |
422 | invalid_state_transition |
AuthenticationError |
401 | unauthorized |
ForbiddenError |
403 | forbidden |
NotFoundError |
404 | not_found |
ConflictError |
409 | conflict |
RateLimitError |
429 | rate_limited (carries retryAfter) |
SettlementError |
422 | chain_reverted, insufficient_confirmations, signing_required |
StepUpRequiredError |
401 | aal_required |
APIError |
5xx | internal_error (and unknown future codes) |
NetworkError |
— | network_error (request never landed) |
Every error carries code, status, message, requestId, and optionally
param for field-level errors. requestId is the value to quote in support.
Idempotency
The SDK auto-attaches an Idempotency-Key to every money-adjacent write
(create + refund + send + rotate). Pass an explicit key when you have one
tied to your domain object — that's safer because retries from your own
systems won't generate a fresh key:
await os.checkouts.create({
mode: "payment",
customerId: "cus_1",
invoiceId: "inv_1",
successUrl: "https://example.com/done",
}, { idempotencyKey: `checkout-${orderId}` });(The SDK's resource methods construct the key automatically; pass-through
is exposed via os.http.request(...) for advanced use.)
Webhooks
Verify signed deliveries from webhook_endpoints with the verifyWebhook
helper — it checks the HMAC-SHA256 in constant time and rejects stale
timestamps:
import { verifyWebhook, WebhookVerificationError } from "@opensettle/sdk";
app.post("/webhook", async (req, res) => {
try {
const { data } = verifyWebhook<{ id: string; type: string }>({
rawBody: req.rawBody, // exact bytes received
signatureHeader: req.header("x-opensettle-signature"),
secret: process.env.WEBHOOK_SECRET!,
});
if (data.type === "payment.confirmed") {
// ship the goods, mark the order paid
}
res.sendStatus(200);
} catch (err) {
if (err instanceof WebhookVerificationError) {
// err.reason: "missing_header" | "malformed_header"
// | "stale_timestamp" | "signature_mismatch"
// | "invalid_body"
return res.status(400).end(err.reason);
}
throw err;
}
});The signature format is t=<unix>,v1=<hex_hmac_sha256> over ${t}.${rawBody}.
Default tolerance is 5 minutes; pass tolerance: <seconds> to override (or
0 to disable, only in test code).
Capture the raw body. Frameworks that JSON-parse before your handler sees it (Express body-parser default, etc.) destroy the original bytes — you'll get spurious
signature_mismatcherrors. Configure raw-body access on the webhook route only.
Resources
os.customers—list,retrieve,create,update,delos.products—list,retrieve,create,update,delete,listPrices,createPrice,deletePriceos.invoices—list,retrieve,create,send,remind,voidos.checkouts—create,retrieveos.subscriptions—list,retrieve,create,pause,resume,cancel,changePlanos.payments—list,retrieve,refund,refundBroadcastos.webhookEndpoints—list,retrieve,create,update,del,rotateSecret,test
Each method returns the typed resource. Refer to the API reference for the full field set per resource.
Test mode
Use a sk_test_… key against the same hostname — there is no separate test
host. Test-mode wallets, customers, and payments live in their own scope and
don't bleed into live data.
const os = new OpenSettle({
apiKey: process.env.OPENSETTLE_TEST_KEY!,
workspaceId: process.env.OPENSETTLE_WORKSPACE!,
testMode: true, // assert this is a test key
});Non-custodial settlement
OpenSettle never holds customer or merchant funds — payments transfer
directly from the customer's wallet to your settlement wallet on-chain.
Our platform fee is accrued separately and billed once a month. The SDK
reflects that: payments.refund() returns an unsigned-tx envelope your
wallet signs and broadcasts; we never have keys that can move funds.
See the security architecture docs for the full posture.
License
MIT. See LICENSE.