Schedule Bill Payments

Schedule Bill 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-tokens 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-tokens \
  -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 webhook confirmations guide for the exact pay.* event states, and the Webhooks guide for registering an endpoint and verifying signatures.

Related

Was this page helpful?