Developers

Embed bill pay in your product · v1.0.0

MVP scope
ML
Get to a running demo in 12 minutes

Quick start

Three steps to render the embedded bill pay surface in your product. Every step is sandbox-safe — no real money moves.

1

Install the React component library

Single npm package — pulls in components, styles, and TypeScript types.

bash
npm install @rutter/billpay-react
2

Mint a session JWT on your backend

Each end customer gets a short-lived token scoped to one entityId. Issue it from your authenticated route.

ts·server/api/billpay/session.ts
import { signJwt } from "@rutter/billpay-server";

export async function POST(req: Request) {
  const user = await getCurrentUser(req);
  const token = signJwt({
    entityId: user.organizationId,        // scopes UI to this customer
    actor: { id: user.id, email: user.email },
    scope: ["bills:read", "bills:write", "payments:write"],
    expiresIn: "1h",
  });
  return Response.json({ token });
}
3

Drop the component into your app

The token wires up auth + entity scoping. Theme via CSS variables to match your design system.

tsx·src/app/billpay/page.tsx
import { BillPayProvider, BillsTable } from "@rutter/billpay-react";
import "@rutter/billpay-react/styles.css";

export default function BillPayPage({ token }: { token: string }) {
  return (
    <BillPayProvider token={token} theme="dark" accent="#5c7e80">
      <BillsTable />
    </BillPayProvider>
  );
}

React components

All surfaces in this demo are real components in @rutter/billpay-react. Compose them, theme them, or use the full inbox in one line.

<BillPayProvider>

Auth + theming context. Wrap the rest.

props: token · theme · accent · onError

<BillsInbox>

Full bill pay page: KPIs, tabs, table, upload.

props: filter · onBillSelect

<BillsTable>

Just the table. Filter, sort, paginate.

props: status · limit · onRowClick

<PayBillDialog>

5-step pay flow modal with invoice preview.

props: billId · onComplete · open

<InvoicePreview>

Mercury-style rendered invoice pane.

props: billId · zoom · controls

<RecipientsList>

Vendor master with W-9 status.

props: search · w9Filter

<ApprovalsPanel>

Pending approvals with one-click approve.

props: policyId · onApprove

<AuditLog>

Signed event stream + CSV export.

props: since · entityId

Theming tokens overridable: --rutter-bg --rutter-surface --rutter-foreground --rutter-accent --rutter-radius + 12 more.

JWT session

Backend mints a short-lived JWT. The UI scopes itself to one customer entity. No customer data leaks across tenants — the token is the entire trust boundary.

Token claims

json
{
  "iss": "anatomy.com",
  "sub": "user_8a2c1f",
  "entityId": "ent_pacific_health",
  "actor": {
    "id": "user_8a2c1f",
    "email": "marcus@pacifichealth.com",
    "role": "approver"
  },
  "scope": [
    "bills:read",
    "bills:write",
    "payments:write"
  ],
  "iat": 1717012800,
  "exp": 1717016400
}

Verify on the wire

ts
import { verifyJwt } from "@rutter/billpay-server";

const claims = await verifyJwt(token, {
  issuer: "anatomy.com",
  audience: "rutter-billpay",
});

// claims.entityId is enforced
// across every API call

Default expiry: 1 hour

Configurable per-org. Refresh tokens are not used; mint a fresh JWT on the backend any time the UI needs one. The component library auto-refetches on 401.

TypeScript SDK

Server-side SDK powers your backend. Same auth model — pass a connection-scoped API key. Idempotent by default.

ts·server/billpay-client.ts
import { Rutter } from "@rutter/billpay";

const rutter = new Rutter({
  apiKey: process.env.RUTTER_KEY!,
  connectionId: "conn_pacific_health",
});

// List unpaid bills for an entity
const bills = await rutter.bills.list({
  entityId: "ent_pacific_health",
  status: ["draft", "pending_approval", "approved"],
  limit: 50,
});

// Create a bill from OCR-extracted data
const bill = await rutter.bills.create({
  entityId: "ent_pacific_health",
  vendorId: "ven_mckesson",
  amount: 38900_00,                        // cents
  currency: "USD",
  invoiceNumber: "MCK-77194",
  dueDate: "2026-05-01",
  glCode: "5110",
  attachments: [{ url: "..." }],
});

// Schedule payment via Anatomy's bank rail
const payment = await rutter.payments.create({
  billId: bill.id,
  rail: "ach",                             // | "wire" | "check" | "rtp"
  fundingAccountId: "fund_ops_payroll",
  scheduledFor: "2026-05-04",
}, {
  idempotencyKey: `pay_${bill.id}_${Date.now()}`,
});

Other languages on the same OpenAPI spec: @rutter/billpay-py · @rutter/billpay-go · com.rutter:billpay (Java) · RutterBillPay (.NET).

Webhooks

Every state transition fires an HMAC-signed webhook. Required for closing the loop with Anatomy's bank rail on paid / failed.

EventWhen it fires
bill.createdBill ingested via OCR or ledger sync
bill.updatedAny field on a bill changes
bill.approval_requestedBill enters pending approval
bill.approvedAll required approvers signed off
bill.rejectedApprover declined
payment.scheduledPayment queued for send
payment.initiatedRail call made to bank API
payment.paidBank confirmed funds received
payment.failedRail returned an error / NSF / return

Sample payload

json
{
  "id": "evt_01HXYZ7K4M",
  "type": "payment.paid",
  "createdAt": "2026-05-05T09:24:18Z",
  "data": {
    "paymentId": "pay_01HXYZ7K3N",
    "billId": "bill_01HXYZ7K2P",
    "amount": 3890000,
    "currency": "USD",
    "rail": "ach",
    "vendorBankReference": "JPM-2026050518-29874"
  },
  "signature": "v1=t1717590258,sig=8f3a..."
}

Verify with rutter.webhooks.verify(rawBody, signatureHeader, secret). Replays accepted within 5 minutes; later replays rejected.

Sandbox

Same API as production. No real money moves. Test invoices and webhook fixtures included.

Sandbox base URL

https://sandbox.rutterapi.com

Test API key (per workspace)

sk_test_4eC39HqLyjWDarjtT1zdp7dc

Test fixtures

bash
# Drive an OCR ingestion
$ curl -X POST https://sandbox.rutterapi.com/v1/billing/ingest \
    -H "Authorization: Bearer sk_test_..." \
    -F "file=@./fixtures/stryker-invoice.pdf"

# Force a payment failure (returns NSF on next .paid attempt)
$ curl -X POST https://sandbox.rutterapi.com/v1/sandbox/force_failure \
    -H "Authorization: Bearer sk_test_..." \
    -d '{"paymentId": "pay_...", "reason": "insufficient_funds"}'

Anatomy bank API SDK

Anatomy's bank API isn't public-facing today (Roger flagged this on 2026-04-30, 25:39). Rutter writes a typed SDK that wraps it as part of the V1 build — net-positive deliverable.

@anatomy/bank-rails

Server-side SDK. Wraps the four payment-init endpoints + status webhooks + funding-account selection.

anatomy-bank.openapi.yaml

Generated OpenAPI 3.1 spec. Works with any code generator Anatomy wants in the future.

ts·rutter-orchestration/anatomy-bank.ts
import { AnatomyBank } from "@anatomy/bank-rails";

const bank = new AnatomyBank({
  apiKey: process.env.ANATOMY_BANK_KEY!,
  environment: "production",
});

// Initiate an ACH (the rail Rutter calls when a bill ships)
const txn = await bank.payments.ach.initiate({
  fromAccountId: "fund_ops_payroll",      // Anatomy's funding account
  amount: 3890000,                         // cents
  currency: "USD",
  recipient: {
    routingNumber: "880401010",
    accountNumber: "158856789",
    accountName: "McKesson Pharmaceutical",
  },
  reference: `bill_${billId}`,
}, {
  idempotencyKey: paymentInitKey,
});

// Status webhooks land back on Rutter
// → fired into the bill state machine

What Anatomy provides for the SDK build

  1. POST endpoints per rail (ACH, Same-day ACH, Wire, Check)
  2. Payment-status webhook delivery (or polling endpoint)
  3. Idempotency-key support on payment-init
  4. Funding-account selection by ID
  5. Sandbox credentials