Documentation

Accept Payments

Securely store cards and pay bills with the Elements SDK. Card numbers live only inside a BillerAPI-hosted iframe — they never reach your servers, your bundle, or any postMessage. Both flows launch with a server-minted pay_token.

Two payment flows

FlowWhat it doesonSuccess first arg
.addPaymentMethod()Collect + tokenize a card. No money moves.payment_method_id
.pay()Pay a specific bill with a stored or new card.payment_attempt_id

Step 1 — Mint a pay_token (server-side)

Mirror the link-token flow. Call POST /v1/pay/token/create from your server with your client credentials. The token is short-lived (15 minutes) and scoped to a single client_user_id with an optional bill_id, amount, or pre-bound payment method.

curl -X POST https://sandbox.api.billerapi.com/v1/pay/token/create \
  -H "Authorization: Bearer $BILLERAPI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "client_user_id": "user_42",
    "bill_id": "bill_123",
    "amount": { "value": 13160, "currency": "USD" }
  }'

# => {
#   "success": true,
#   "pay_token": "payt_...",
#   "pay_token_id": "...",
#   "expires_at": "2026-06-28T12:15:00Z"
# }

client_id is authoritative from auth context

client_id is resolved from your authenticated request, never trusted from the request body. Amount and bill scope on the token are enforced at payment time — a /payments call whose bill_id does not match the token's scope is rejected.

Step 2 — Launch the flow (browser)

Pass the pay_token to addPaymentMethod or pay. The hosted iframe validates the token, collects the card, and tokenizes it. Your code never sees the PAN.

import { BillerApiElements } from 'billerapi-js';

const elements = new BillerApiElements({ clientId: 'your_client_id', environment: 'sandbox' });

elements.addPaymentMethod({
  payToken: 'payt_xxx',
  onSuccess: (paymentMethodId, metadata) => {
    // Stored card — last_4 + card_brand are safe to display
    console.log('Saved', paymentMethodId, metadata.card_brand, metadata.last_4);
  },
  onExit: (error) => {
    if (error) console.error(error.code, error.message);
  },
}).open();

The PCI boundary

Card data never crosses postMessage

The card number and CVV live only in the BillerAPI-origin hosted iframe's local state. They are never sent over postMessage, never reach your backend, and never touch BillEBox. The SDK only ever receives a tokenized payment_method_id plus display-safe last_4 and card_brand.

The hosted card form collects exactly three fields — card number, expiry, and CVV. No cardholder name or ZIP is collected, and the card is tokenized through Vault before any identifier leaves the iframe.

Pay is sandbox-first

Money movement is gated

The .pay() flow is built and testable in sandbox today. Live payment execution is gated behind the payment-execution backend; when execution is disabled the hosted page renders a clear sandbox state instead of charging a card. Build and QA against sandbox now — going live is a configuration flip, not a code change. .addPaymentMethod() (no money movement) is available end-to-end.

Webhooks are the source of truth

Confirm payments server-side

onSuccess means the attempt was submitted, not that it settled. A payment_attempt_id is not a paid bill. Wait for the pay.succeeded webhook (or pay.failed) before fulfilling. SDK callbacks run in the browser and can be lost.

See the Webhooks guide for registering an endpoint and verifying signatures.

Related

Was this page helpful?