| name | payram-topup-wallet-integration |
| description | Integrate PayRam into an EXISTING app using the recommended top-up wallet pattern — credit crypto payments to a user's in-app wallet balance first, then debit invoices from that balance. Handles every crypto payment reality (overpayment, underpayment, multiple sends, late payments, duplicate webhooks) as balance states instead of payment exceptions. Covers the ledger schema, the credit/debit flows, idempotency, concurrency, reconciliation, and PayRam's cumulative filled_amount_in_usd semantics. Use when adding PayRam to an app that has its own users/invoices/orders, when payments may not exactly match invoice amounts, or when building credits, prepaid balances, or usage billing on crypto rails. |
PayRam Top-Up Wallet Integration (recommended for existing apps)
New store with no payment history? Use the checkout/plugin path (payram-checkout-integration, or the WooCommerce plugin). Existing app with users and invoices? Use THIS pattern.
Why top-up-first, not pay-per-invoice
Crypto payments are approximate by nature. A customer paying a $50 invoice may send $49.20 (gas mental-math), $60 (round number), $50 in two transfers, or the right amount two hours after the link "expired" — the funds still arrive on-chain. If you bind payments directly to invoices, every one of those is an exception you must code for.
The top-up pattern flips it: whatever arrives is credited to the user's wallet balance — exactly the amount received. Invoices are then debited from the balance as a plain, atomic app-side operation. Every crypto quirk becomes a balance state, not a payment failure:
| Crypto reality | Direct-to-invoice | Top-up wallet |
|---|
| Overpayment (OVER_FILLED) | Refund flow needed | Excess stays as balance for next invoice |
| Underpayment (PARTIALLY_FILLED) | Failed payment, retry whole amount | Balance credited; top up just the difference |
| Two partial sends | Manual matching | Both credit; invoice settles when balance suffices |
| Late payment after "expiry" | Orphaned funds | Still credited; user spends it normally |
| Refund requested | On-chain refund per payment | App-side reversal entry (+ PayRam payout only if crypto must leave) |
Architecture: the ledger
Three tables. The ledger is append-only; the balance is derived (cache it, but the ledger is truth).
CREATE TABLE user_wallets (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE,
balance_usd NUMERIC(20,8) NOT NULL DEFAULT 0 CHECK (balance_usd >= 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE wallet_ledger (
id BIGSERIAL PRIMARY KEY,
wallet_id BIGINT NOT NULL REFERENCES user_wallets(id),
entry_type TEXT NOT NULL CHECK (entry_type IN
('credit','credit_adjustment','debit','debit_reversal')),
amount_usd NUMERIC(20,8) NOT NULL CHECK (amount_usd > 0),
payram_reference_id TEXT,
invoice_id BIGINT,
memo TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX one_credit_per_reference
ON wallet_ledger (payram_reference_id) WHERE entry_type = ;
invoices (
id BIGSERIAL ,
user_id ,
amount_usd (,) ,
status TEXT
(status (,,,)),
paid_at TIMESTAMPTZ
);
Use NUMERIC, never floats. Everything is denominated in USD — PayRam's filled_amount_in_usd gives you the USD value of what actually arrived, so a single-currency ledger sidesteps FX entirely.
The critical PayRam semantic: filled amounts are CUMULATIVE
One payment reference can fire several webhooks as funds arrive: PARTIALLY_FILLED (filled_amount_in_usd: 20.00) → FILLED (filled_amount_in_usd: 50.00). The amount is the running total for that reference, not a delta. Your credit logic must be:
on webhook (reference_id, status, filled_amount_in_usd):
if status not in (PARTIALLY_FILLED, FILLED, OVER_FILLED): ack and ignore
in one DB transaction:
prior = SUM(amount) of credit + credit_adjustment rows for reference_id
delta = filled_amount_in_usd - prior
if delta <= 0: ack (duplicate or out-of-order webhook — already credited)
insert ledger row:
entry_type = 'credit' if prior == 0 else 'credit_adjustment'
amount_usd = delta, payram_reference_id = reference_id
balance_usd += delta
then: try_settle_open_invoices(user)
This one function absorbs duplicates (delta ≤ 0), out-of-order delivery, partial-then-full fills, and overpayment — with zero special cases.
The flows
Flow A — invoice settlement (spend from balance)
create invoice → BEGIN; SELECT balance FROM user_wallets WHERE user_id=? FOR UPDATE;
if balance >= invoice.amount:
insert debit row (invoice_id), balance -= amount, invoice.status='paid'; COMMIT
else:
invoice.status='awaiting_funds'; COMMIT → go to Flow B for the shortfall
FOR UPDATE (or SERIALIZABLE) makes concurrent debits of one wallet safe — the balance check and the debit are one atomic unit.
Flow B — top-up (get funds in)
shortfall = invoice.amount - balance
POST {payram}/api/v1/payment (API-Key header)
{ customerEmail, customerID: "<your user_id>", amountInUSD: shortfall, invoiceID: "<your invoice id>" }
→ show returned url to the user
customerID = your user id — it's how the webhook maps back to the wallet.
invoiceID is optional metadata; the ledger does NOT rely on it (credits are wallet-level).
- Ask for the shortfall, not the full invoice — existing balance already counts.
Flow C — the credit webhook (funds arrived)
Register your webhook in the PayRam project. PayRam POSTs snake_case JSON with an API-Key header equal to your configured shared secret — verify it with a constant-time compare. Then run the cumulative-credit logic above, then re-attempt Flow A for any awaiting_funds invoices of that user.
Flow D — refunds & cancellations
- App-level refund (user keeps money in your app): insert
debit_reversal for the invoice → balance goes back up. No crypto moves.
- Crypto must actually leave: pay out via PayRam's payout flow (see
payram-payouts) AND insert a matching debit (memo: refund payout) so the ledger mirrors reality.
Case matrix (all of them)
| # | Case | What happens |
|---|
| 1 | Exact payment | credit = invoice → Flow A settles immediately |
| 2 | Overpayment | credit > invoice → invoice paid, excess remains as balance |
| 3 | Underpayment | credit < invoice → invoice awaiting_funds; UI offers top-up link for shortfall |
| 4 | Multiple sends, one reference | cumulative webhooks → base credit + adjustments; settles when total suffices |
| 5 | Multiple separate top-ups | independent references → independent credits; balance accumulates |
| 6 | Late payment ("expired" link) | funds still arrive on-chain → webhook still fires → normal credit |
| 7 | Duplicate webhook delivery | delta ≤ 0 → ignored (retry-safe by construction) |
| 8 | Out-of-order webhooks | cumulative math is order-independent |
| 9 | Concurrent invoice debits | row lock in Flow A serializes them; CHECK (balance >= 0) is the backstop |
| 10 | Refund | reversal entry (app-level) or payout + debit (crypto leaves) |
| 11 | Reconciliation drift | nightly job: PayRam payment search (sum filled_amount_in_usd per reference) vs ledger credits — must match to the cent |
PayRam API surface you use
| Purpose | Call | Auth |
|---|
| Create top-up link | POST /api/v1/payment {customerEmail, customerID, amountInUSD, invoiceID?} | API-Key header |
| Check one payment | GET /api/v1/payment/reference/{reference_id} | reference acts as capability |
| Credit webhook (inbound) | your endpoint receives {reference_id, customer_id, status, filled_amount_in_usd, ...} | verify API-Key shared secret |
| Reconciliation | POST /api/v1/external-platform/{id}/payment/search (JWT) | dashboard JWT |
Statuses that credit: PARTIALLY_FILLED, FILLED, OVER_FILLED. Ignore OPEN; treat CANCELLED as informational (no funds → no credit).
Generate the code
The MCP tool generate_topup_integration_snippet emits the ledger SQL, the cumulative-credit webhook handler, and the atomic settle function for your framework — start there, then adapt table names to your app.
Rollout checklist
- Create the three tables; wire the webhook endpoint (verify shared secret, constant-time).
- Implement cumulative credit + atomic settle (use the generator).
- Point a test invoice at testnet, pay the link partially, verify
awaiting_funds → top up → paid.
- Add the nightly reconciliation query before going to mainnet.
- Go live; monitor
check_node_sync — a lagging chain delays credits, not correctness.