@intrpay/node
v0.5.0
Published
Node.js server SDK for the Intrpay API
Maintainers
Readme
@intrpay/node
Node.js server SDK for the Intrpay API. Use it from your backend to call the API, mint scoped embed sessions, and verify incoming webhooks.
This is a server-side SDK. Your API key is a secret and must never be exposed to the browser.
Install
npm install @intrpay/nodeRequires Node.js 18+.
Usage
Create a client
import { createIntrpayClient } from '@intrpay/node';
const intrpay = createIntrpayClient({
apiKey: process.env.INTRPAY_API_KEY!, // from the Intrpay dashboard
});
// Target the test environment (api.intrpay.dev):
const intrpayTest = createIntrpayClient({
apiKey: process.env.INTRPAY_API_KEY!,
environment: 'test',
});Options:
| Option | Default | Description |
| ------------- | -------------- | ------------------------------------------------------------------------------- |
| apiKey | (required) | API key from the Intrpay dashboard. |
| environment | 'production' | API environment: 'production' (api.intrpay.us) or 'test' (api.intrpay.dev). |
| version | 2026-06 | Calver contract version sent as Intrpay-Version. |
| timeoutMs | none | Per-request timeout in milliseconds. |
Every request is sent with the x-api-key and Intrpay-Version headers.
Products
await intrpay.products.list();
await intrpay.products.get(productId);
await intrpay.products.create({ name: 'Pro plan', unitPrice: 4900 });
await intrpay.products.update(productId, { unitPrice: 5900 });
await intrpay.products.updateInventory(productId, 42);
await intrpay.products.delete(productId);Payment links
await intrpay.paymentLinks.list();
await intrpay.paymentLinks.get(id);
await intrpay.paymentLinks.create({ name: 'Donation', card: true });
await intrpay.paymentLinks.update(id, { title: 'Updated' });
await intrpay.paymentLinks.activate(id);
await intrpay.paymentLinks.deactivate(id);
await intrpay.paymentLinks.delete(id);Contacts
await intrpay.contacts.list();
// `search` matches name, email, phone, or company name.
await intrpay.contacts.list({ search: 'acme.com', limit: 25, offset: 0 });
const contact = await intrpay.contacts.create({
companyName: 'Acme Inc',
email: '[email protected]',
});
await intrpay.contacts.get(contact.id);
await intrpay.contacts.update(contact.id, { phone: '5551234567' });Contacts are unique by id. Display names must be unique per account (HTTP 409
on an explicit duplicate). Email and phone may repeat. upsert does not merge
on email or phone. force still creates a contact backed by a stub user;
display-name uniqueness still applies.
Payment methods (ACH)
Only ACH payment methods can be created through the API - cards go through the embed flow. Two ways to create one:
From raw bank details:
const method = await intrpay.contacts.paymentMethods.create(contactId, {
type: 'ach',
ach: {
accountHolderName: 'Acme Inc',
routingNumber: '021000021',
accountNumber: '123456789',
accountType: 'checking',
accountOwnerType: 'company',
},
default: true,
});From a Plaid connection (enables balance checks before charging):
// 1. Mint a link token and render Plaid Link in your own UI.
const { linkToken, plaidConnectionId } = await intrpay.plaid.createLinkToken({ contactId });
// 2. After Plaid Link succeeds, exchange the public token.
await intrpay.plaid.exchange({
publicToken, // from Plaid Link onSuccess
plaidConnectionId,
plaidAccountId, // optional: the account the user picked
});
// 3. Create the payment method from the connection.
const method = await intrpay.contacts.paymentMethods.create(contactId, {
plaidConnectionId,
default: true,
});List a contact's saved (masked) methods:
await intrpay.contacts.paymentMethods.list(contactId);Change which method is the default, or remove one. Payment details cannot be replaced - add a new method instead. Deleting a method that has ever been charged keeps it internally so past transactions stay rechargeable; it simply stops listing and can no longer be used.
await intrpay.contacts.paymentMethods.update(contactId, paymentMethodId, { default: true });
await intrpay.contacts.paymentMethods.delete(contactId, paymentMethodId);Subscriptions
await intrpay.subscriptions.list();
await intrpay.subscriptions.list({ status: 'active', contactId });
// One product per subscription. A start on/before today bills immediately.
const subscription = await intrpay.subscriptions.create({
contactId,
productId,
frequency: 'monthly',
paymentMethodId: method.id, // auto-charge with the saved ACH method
});
await intrpay.subscriptions.get(subscription.id);Billing everything on one day
By default each subscription bills on its own start-date anniversary, so a
contact with three products can end up with three separate charges a month.
alignBillingDate puts a new subscription on the day the contact already
bills, and prorate charges only the days between signup and that day.
await intrpay.subscriptions.create({
contactId,
productId,
frequency: 'monthly',
paymentMethodId: method.id,
alignBillingDate: true, // adopt this contact's existing monthly billing day
prorate: true, // charge only the partial period up to it
});The contact's first subscription has nothing to align to, so it bills a full
period immediately and becomes the day everything added later lands on. Pass an
ISO date instead of true to pick the day yourself. Subscriptions sharing a
contact and billing day are consolidated into one invoice.
// Pricing/settings only. Schedule fields (frequency, billingInterval,
// startDate) are immutable - cancel and create a new subscription instead.
await intrpay.subscriptions.update(subscription.id, {
unitPrice: 15,
prorateChange: true, // settle the mid-cycle price change now
});
await intrpay.subscriptions.pause(subscription.id, { reason: 'Customer request' });
await intrpay.subscriptions.resume(subscription.id);
await intrpay.subscriptions.cancel(subscription.id, { reason: 'Churned' });
await intrpay.subscriptions.archive(subscription.id);
// Invoice the current billing period now instead of waiting for the daily cron
await intrpay.subscriptions.invoiceNow(subscription.id);Invoices
Create, list, update, and manage invoices. action defaults to send: a
bare create / update publishes the invoice AND emails the customer. Pass
action: 'save' to skip the email, or action: 'draft' on create to save a
draft.
const { invoices, total } = await intrpay.invoices.list({ contactId });
await intrpay.invoices.list({ subscriptionId: subscription.id, status: 'open' });
await intrpay.invoices.get(invoiceId);
// Publishes and emails (action defaults to 'send')
const invoice = await intrpay.invoices.create({
contactId,
lineItems: [{ productId, name: 'Consulting', quantity: 1, unitPrice: 150 }],
terms: 'net_30',
paymentCollection: { type: 'none' },
});
// Publish without emailing
await intrpay.invoices.create({
contactId,
lineItems: [{ productId, name: 'Fee', quantity: 1, unitPrice: 50 }],
action: 'save',
});
await intrpay.invoices.update(invoice.id, {
comments: 'Updated note',
action: 'save',
});
await intrpay.invoices.send(invoice.id);
await intrpay.invoices.void(invoice.id);
await intrpay.invoices.delete(invoice.id);Credit memos
Create, list, update, apply, and void credit memos. Unlike invoice line
items, credit memo line items may omit productId for a free-text line.
const { creditMemos, total } = await intrpay.creditMemos.list({ contactId });
await intrpay.creditMemos.get(creditMemoId);
// Defaults to status 'draft'; pass status: 'open' to make it immediately
// available to apply.
const creditMemo = await intrpay.creditMemos.create({
contactId,
creditMemoDate: new Date().toISOString(),
lineItems: [{ name: 'Refund - damaged item', quantity: 1, unitPrice: 25 }],
status: 'open',
});
await intrpay.creditMemos.update(creditMemo.id, { note: 'Approved by support' });
const application = await intrpay.creditMemos.apply(creditMemo.id, {
invoiceId,
amount: 25,
});
await intrpay.creditMemos.unapply(creditMemo.id, application.id);
await intrpay.creditMemos.void(creditMemo.id);Statements
A statement is an immutable snapshot of a contact's activity for a period,
plus a link the customer can pay their open invoices from. Statements have no
status: sentAt is the only send signal, and deleting one never touches the
ledger, since any payment taken through its link lives on the invoices.
Numbers are plain ('1000') and come from their own sequence, so a statement
number may coincide with an invoice number.
const { statements, total } = await intrpay.statements.list({ contactId });
await intrpay.statements.get(statementId);
// See what a period would say without creating anything.
const preview = await intrpay.statements.preview({
contactId,
periodStart: '2026-07-01',
periodEnd: '2026-07-31',
});
// `include` only filters which rows are listed - opening and closing balances
// always come from the full ledger. Transactions and estimates are off by
// default.
const statement = await intrpay.statements.create({
contactId,
periodStart: '2026-07-01',
periodEnd: '2026-07-31',
include: { transaction: true },
message: 'Thanks for your business.',
allowPartialPayments: true,
send: true,
});
await intrpay.statements.send(statement.id, { cc: ['[email protected]'] });
await intrpay.statements.delete(statement.id);Payment plans
Split one or more existing invoices into scheduled installments.
recurringSchedule is required once a plan has 2 or more lines.
const { paymentPlans, total } = await intrpay.paymentPlans.list({ contactId });
await intrpay.paymentPlans.get(paymentPlanId);
const plan = await intrpay.paymentPlans.create({
invoiceIds: [invoice.id],
contactId,
paymentMethodId: method.id,
recurringSchedule: 'monthly',
lines: [
{ amount: 50, scheduledDate: '2026-08-01' },
{ amount: 50, scheduledDate: '2026-09-01' },
],
});
// Update top-level fields, or pass `lines` to replace the schedule entirely.
await intrpay.paymentPlans.update(plan.id, { description: 'Renamed plan' });
await intrpay.paymentPlans.pause(plan.id);
await intrpay.paymentPlans.cancel(plan.id);
// Charge a scheduled/failed installment. Pass idempotencyKey to safely retry.
await intrpay.paymentPlans.chargeLine(plan.id, plan.lines[0].id, {
idempotencyKey: 'unique-retry-key',
});Payments
Record and delete manual (non-gateway) invoice payments - cash, check, wire, ACH, card, DAF, Zelle, or other. Gateway charges are a separate resource; see Transactions.
const { payments, total } = await intrpay.payments.list({ contactId });
await intrpay.payments.get(paymentId);
const payment = await intrpay.payments.create({
contactId,
type: 'check',
refNumber: '1234',
paymentDate: new Date().toISOString(),
amount: 100,
paymentLines: [{ invoiceId: invoice.id, amount: 100 }],
});
await intrpay.payments.delete(payment.id);Transactions
Ad-hoc charges against a contact's stored payment method, plus refund and
void. paymentMethodId must reference a payment method already on file -
raw card/ACH details are never accepted here.
const { transactions, total } = await intrpay.transactions.list({ contactId });
await intrpay.transactions.get(transactionId);
// Filter to what one payment link collected. Worth knowing for a `checkout`
// link: its invoice is raised only after the charge succeeds and carries its
// own pay-link, so the transaction is the one record of which link was paid.
const { transactions: collected } = await intrpay.transactions.list({ paymentLinkId });
// Pass idempotencyKey to safely retry without double-charging
const transaction = await intrpay.transactions.charge(
{
contactId,
paymentMethodId: method.id,
amount: 50,
note: 'Manual charge for services rendered',
},
{ idempotencyKey: 'unique-retry-key' }
);
await intrpay.transactions.refund(transaction.id, { amount: 20, note: 'Partial refund' });
await intrpay.transactions.void(transaction.id, { note: 'Entered in error' });Projects
Job-costing projects group a customer's invoices and estimates under a
budget/timeline. A project's contactId cannot be changed after creation.
const { projects } = await intrpay.projects.list({ contactId });
const project = await intrpay.projects.create({
contactId,
name: 'Kitchen remodel',
budget: 15000,
startDate: '2026-08-01',
endDate: '2026-10-01', // cannot be before startDate
});
await intrpay.projects.get(project.id);
await intrpay.projects.update(project.id, { status: 'completed' });
// Both the document and the project must belong to the same contact.
await intrpay.projects.assignInvoice(project.id, invoice.id);
await intrpay.projects.unassignInvoice(project.id, invoice.id);
await intrpay.projects.assignEstimate(project.id, estimate.id);
await intrpay.projects.unassignEstimate(project.id, estimate.id);
await intrpay.projects.archive(project.id);Checkout sessions
Sell to one of your contacts in the browser. You build the cart on your own backend, we price it, and the invoice is raised from the charge:
const checkout = await intrpay.checkout.sessions.create({
contactId,
title: 'Pro plan + onboarding',
items: [
{ productId: 'prod_pro_plan' },
// An add-on the customer can tick, and buy more than one of.
{ productId: 'prod_onboarding', optional: true },
// Sell the same product at a negotiated price on this cart only.
{ productId: 'prod_seats', quantity: 5, unitPrice: 12 },
],
methods: { card: true, ach: true },
});
// The token is already bound to this cart - no id reaches the browser.
res.json({ token: checkout.token });import { Checkout } from '@intrpay/react';
<Checkout token={token} onSuccess={(r) => console.log(r.checkoutSessionId)} />;Every line names a catalog product, so the invoice the charge raises carries
productId and the sale lands in product revenue and inventory rather than as
a loose line of text. Prices are resolved and frozen when the session is
created: the browser only ever sends back line ids and add-on quantities, so a
compromised host page cannot name its own total.
Read the outcome back from your backend rather than trusting the browser:
const session = await intrpay.checkout.sessions.get(checkout.id);
if (session.status === 'paid' && session.paymentStatus === 'approved') {
grantAccess(session.contactId, { invoiceId: session.invoiceId });
}status is paid as soon as the charge lands, but ACH takes days to clear -
paymentStatus is pending until it does. If you are granting something the
customer paid for and cannot easily take it back, wait for approved.
No invoice exists until the charge lands, so an abandoned or declined checkout
costs you an expired session rather than an unpaid document in your books. A
session stops being payable after ttlSeconds (an hour by default), or when you
give up on it early:
await intrpay.checkout.sessions.expire(checkout.id);A paid session cannot be paid again, and cannot be expired - abandoning one can never bury a sale.
Embed sessions
Mint a short-lived, scoped token for the browser embed (e.g. @intrpay/react):
const { token, expiresAt } = await intrpay.embed.sessions.create({
contactId,
scope: ['invoices:read:contact', 'payment_methods:write:contact'],
ttlSeconds: 900,
});contactId is required when any requested scope ends with :contact.
Request only what the surface you are rendering needs:
| Scope | What it allows |
| ------------------------------- | ------------------------------------------------------------------------------------- |
| invoices:read:contact | Read the contact's invoices (<InvoiceList>, <InvoiceDetail>) |
| invoices:pay:contact | Pay one of them (<Checkout invoiceId>), including collecting the method used to pay |
| payment_methods:read:contact | List the contact's saved methods |
| payment_methods:write:contact | Save a new one without charging (<AddPaymentMethod>) |
Selling from your catalog is not one of these. A token minted so a customer can settle a bill they already owe should not also be able to transact against your catalog, so that power only comes with a cart it is bound to - create a checkout session and use the token it returns.
Webhooks
Intrpay signs every outbound webhook with HMAC-SHA256 over the raw request body
using the endpoint's signing secret, sent in the X-Webhook-Signature header.
import { constructEvent, verifyWebhook } from '@intrpay/node';
// Express example: use the raw body, not the parsed JSON.
app.post('/webhooks/intrpay', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = constructEvent({
body: req.body, // Buffer
signature: req.get('X-Webhook-Signature') ?? '',
secret: process.env.INTRPAY_WEBHOOK_SECRET!,
});
// event => { event, data, timestamp }
res.sendStatus(200);
} catch {
res.sendStatus(400);
}
});verifyWebhook(...) returns a boolean if you'd rather verify and parse
yourself.
Deliverable events (see the WebhookEventName type): bill.created,
bill.approved, invoice.created, invoice.paid, invoice.voided,
payment.created, payment.deleted, subscription.created,
subscription.updated, subscription.paused, subscription.resumed,
subscription.cancelled, subscription.overdue,
subscription.payment_failed, subscription.expired, and payment_method.created. Subscription
event payloads carry subscriptionId, contactId and accountId
(subscription.payment_failed adds invoiceId and errorMessage);
payment_method.created carries paymentMethodId, contactId and
accountId.
Errors
Non-2xx responses throw an IntrpayError with status and body:
import { IntrpayError } from '@intrpay/node';
try {
await intrpay.products.get('missing');
} catch (err) {
if (err instanceof IntrpayError) {
console.error(err.status, err.body);
}
}