If you want to charge customers from pre-funded balances, topped up using your existing PSP rails, this guide walks through the integration pattern. The push model is consistent across AI billing platforms: the PSP processes the top-up payment and notifies your backend via webhook, your backend creates a balance adjustment in the billing system, and the customer's balance is credited. This guide covers the 5 integration steps, reason-code mapping, and the testing checklist that keeps your books consistent with customer balances.
This guide covers one-time and recurring paid top-ups via balance adjustments. These are moments when money actually moves through your PSP. For hybrid subscriptions with bundled credits that refresh on cycle, and for promotional credits like daily free credits, use the billing system's subscriptions and entitlements instead of an adjustment.
If you are already on Stripe but running into limits on metered billing for AI products, the same pattern applies. For context on where this integration fits in the broader migration path, see how AI companies adopt real-time billing without replacing their stack.
Is this the right pattern for your situation?
This guide covers the case where your team operates the PSP directly through your own merchant account, as opposed to one the billing platform provisions on your behalf. The pattern fits when you want to charge customers from pre-funded balances, topped up via your existing PSP rails, without giving the billing platform any access to that PSP. You are in the right place if any of the following apply:
- You use a PSP other than Stripe (Adyen, Braintree, Razorpay, Checkout.com), or you use Stripe but manage checkout entirely in your own code
- You have existing subscription billing that generates recurring payments you want reflected as wallet credits
- You process payments server-side and need the result to update customer wallet balances in the billing system
- You want the billing platform to have zero access to your payment credentials or cardholder data
How your PSP connects to an AI billing system
Your PSP processes the top-up payment and notifies your backend via webhook. Your backend posts the result to the billing system as a balance adjustment. The billing system credits the customer's balance. The two systems never communicate directly.
Your PSP is the source of truth for payment events. The billing system has no visibility into your processor; it knows nothing about payments, customers, or payment methods until you tell it. Every wallet credit, refund, or promotional credit originates from a call your backend makes to an adjustments endpoint after a payment event fires.
Not every billing system exposes the same primitive. Wallet-first systems (Stigg, Credyt) let you post directly to the customer's balance. Invoice-based systems (Lago, Orb, Metronome) post to a credit ledger that reduces future invoice totals instead. The push pattern works for both; what you post differs.
Idempotency is the load-bearing primitive
The push model is only safe if balance adjustments are idempotent. Every major PSP uses at-least-once delivery, so webhook handlers will receive duplicates. Stripe retries for up to 3 days with exponential backoff. Adyen retries for up to 30 days through its retry queue and requires a 2xx response within 10 seconds before the event enters that queue.
Every PSP produces a unique identifier for each payment: Stripe's payment intent ID, Adyen's pspReference, Braintree's transaction ID. Use this (directly or derived) as the idempotency key when creating the adjustment. Otherwise, create a mapping so a given PSP payment can only produce a single, retry-safe operation on the billing system side. For Adyen specifically, the dedup key is a composite of eventCode and pspReference, not a single field; construct the composite before posting the adjustment.
Reason codes carry accounting meaning
Every adjustment carries a code that tells the billing system how to classify the balance change. The four purposes cover almost every real-world case:
| Purpose | When to use it | Revenue recognition |
|---|---|---|
| Paid top-up | Customer paid via PSP to add funds | Deferred revenue; recognized at consumption |
| Refund | Returning funds previously credited | Reverses recognized revenue |
| Promotional gift | Free credit, no payment involved | Marketing expense (if customer unaware); deferred revenue (if customer expects it) |
| Manual correction | Billing errors, goodwill credits, edge cases | Depends on context; add metadata |
The distinction between a paid top-up and a gift is not cosmetic. Under ASC 606 as interpreted by Deloitte DART, credits customers have a "reasonable expectation of receiving" reduce the transaction price and create a contract liability. Credits customers are "unaware of" may qualify as marketing expense. For a practical walkthrough of when credits become revenue, see revenue recognition for usage-based billing.
Using a top-up code for a promotional credit overstates deferred revenue. Using a gift code for a real payment underbooks revenue. Map your payment event types to reason codes before you write a single line of integration code.
Paid top-ups and gifts can also carry an expiry date. Trial credits, promotional credits, and time-limited grants typically expire; general paid top-ups usually do not. Set the expiry on the adjustment itself, not through a separate endpoint.
Step-by-step: connecting your PSP to an AI billing system
Connecting your PSP requires five steps, in order: map event types to reason codes, process and deduplicate webhook events, resolve the customer reference, post the wallet adjustment with an idempotency key, and handle the API response. Do not start writing handler code before Step 1 is written down.
Step 1: Map your PSP events to reason codes
Produce a mapping table before touching your codebase. Every payment event type your PSP emits needs a decision: which reason code does it correspond to, and does it trigger an adjustment at all?
A working example for Stripe:
| Stripe event | Reason code | Notes |
|---|---|---|
payment_intent.succeeded | Paid top-up | One-time top-up |
invoice.paid | Paid top-up | Subscription payment feeding wallet credits |
charge.refunded | Refund | Full or partial refund |
payment_intent.payment_failed | No adjustment | Do not credit the wallet |
customer.subscription.deleted | No adjustment | Handle access revocation separately |
For Adyen, Braintree, Razorpay, or Checkout.com the event names differ and the mapping logic stays the same. Charge succeeded maps to a paid top-up. Refund issued maps to a refund. Failed charges do not trigger adjustments. Keep this table as your source of truth; it is what you reference six months from now when a finance question arises.
Step 2: Process incoming payment events
Beyond the standard webhook concerns (signature verification, returning 2xx before doing downstream work), two details matter here.
Parse the event and apply your mapping from Step 1. Extract the event type, match it against your mapping table, and decide whether it triggers an adjustment. Events with no matching reason code get logged and dropped. Events that do match need four fields extracted: a unique identifier for this payment event, the customer identifier, the amount, and the currency.
Handle duplicates at the receiver level. The idempotency key on the adjustment handles duplicates at the billing-system level, but deduplicating at the receiver keeps your logs clean and your async queue from doing unnecessary work. Store the event ID and skip processing if you have already seen it. For Adyen specifically, remember the dedup key is the composite of eventCode and pspReference, not either field alone.
Step 3: Resolve the customer reference
Most billing systems expose an external customer ID field. Set it to your own user ID when creating the customer in the billing system. From that point, any customer can be looked up by external ID without storing a separate billing-system-specific ID.
The adjustments endpoint takes the billing system's customer ID in the URL path, so at the point of posting an adjustment you need it. You can retrieve it on demand by external-ID lookup, but if adjustments sit in your hot path, the extra round trip adds latency. The practical approach: at customer creation, store the billing-system customer ID your database returns alongside your user record. One field, written once at signup, and every subsequent adjustment call has what it needs.
If a payment event fires before you have the billing-system customer ID available, do not silently discard the event. Route it to a dead letter queue and alert. The customer has paid and their wallet has not been credited. That is a support incident, not an edge case to handle quietly.
Step 4: Create the balance adjustment
With the mapping in place and the event verified, post the adjustment. The call requires four core fields:
- Transaction ID: a unique identifier for this payment event, used as the idempotency key. Using the PSP's charge or payment-intent ID is a reasonable default since it is already unique and creates a natural audit trail. A generated UUID with the PSP reference in metadata works equally well. What matters is that the same transaction ID is passed on every retry.
- Reason code: from your mapping table in Step 1.
- Asset: the currency or custom unit to credit. For a USD top-up,
USD. For token-denominated billing, the custom asset code. - Amount: the value to credit to the wallet.
Two optional fields are worth using: expiry date for time-limited promotions or trial credits, and metadata for context your finance or support team needs later (subscription period, plan name, campaign ID, original invoice number). A note on amounts: PSPs often return values in the smallest currency unit. Stripe returns cents; a $10 payment comes back as 1000. Confirm your conversion before shipping.
Field names and required formats vary by billing system. Check your platform's docs for the exact schema. For Credyt, the wallets and top-ups guide covers the conceptual model and the create-adjustment endpoint reference covers the parameter schema.
Step 5: Handle the response
The response tells you what happened. Log it in all cases.
Success: the response includes an adjustment ID assigned by the billing system. Log this alongside your PSP transaction ID and the billing-system customer ID. This three-way mapping is your audit trail.
Customer not found: the mapping problem from Step 3. Do not silently discard. Send it to a dead letter queue, fire an alert, and resolve manually. The customer has paid and received nothing.
How to handle refunds, chargebacks, and promotional credits
Each of these requires a different reason code and carries different accounting treatment.
Refunds
When a customer requests a refund, two things must happen in sync: the PSP processes the refund and returns funds to the card, and the billing system reduces the wallet balance. Use the refund reason code, and confirm the amount. A partial refund should reduce the wallet by the partial amount, not the full original top-up. Anti-pattern: issuing the PSP refund without posting the corresponding adjustment. The wallet shows a balance already returned to the card; that balance can then be spent. Treat refund and adjustment as an atomic pair.
Reducing the wallet balance is harder than it looks. If the customer has already spent some or all of the topped-up amount, the refund drives the balance negative. You then need an overdraft policy or a partial-refund rule to handle it. Many companies running pre-funded balances side-step this by declaring credits non-refundable up front. OpenAI does this explicitly in their pricing terms. A clear refund policy stated at purchase avoids the edge cases before they occur.
Subscription payments feeding wallet credits
A common pattern for AI products: a monthly subscription payment runs through your existing PSP, and the customer's wallet is credited with a corresponding allocation of usage credits. The subscription relationship stays entirely in your existing stack. The billing system handles the usage layer.
Listen for the payment-success event, not the invoice-creation event. If an invoice is created but not yet paid, or if it fails and is retried, you do not want the wallet credited until money has actually moved. For deeper treatment of when these credits become recognized revenue, see revenue recognition for usage-based billing.
Promotional credits and gifts
Promotional credits do not originate from a payment event. They are granted programmatically, typically triggered by a business event: signup, referral, milestone, support resolution.
Use the gift reason code. Since there is no PSP transaction ID, generate a stable idempotency key yourself. A reliable pattern is promo_{customer_id}_{campaign_id}, unique per customer per campaign. If the promotion is time-limited, set the expiry date. A 14-day trial credit that does not expire is a billing error waiting to happen.
Manual corrections
Occasionally you need to adjust a wallet balance directly: a billing error, a goodwill credit after an incident, an edge case your integration did not handle. Use the catch-all reason code, generate a unique idempotency key, and put context in metadata: what happened, why, who authorized it. Chargebacks belong here, not under refunds. A chargeback is a forced network reversal rather than a platform-initiated refund; conflating the two creates accounting ambiguity.
Testing before you ship
Run these six scenarios in your PSP's sandbox before going live. Each one maps to a class of production failure you do not want to debug with real money.
- Test every event type in your mapping. Use your PSP's sandbox to fire synthetic events for each row in your Step 1 table. Verify the correct reason code, amount, and customer wallet are used.
- Test duplicate events explicitly. Fire the same webhook twice in quick succession. The wallet balance should change only once. If it changes twice, your idempotency key is not being passed correctly.
- Test the customer-not-found case. Fire a payment event for a customer whose billing-system ID is not in your mapping. Confirm your handler routes it to the dead letter queue and alerts, not silently drops it.
- Test refunds. Issue a refund through your PSP's sandbox and confirm the wallet decreases by the correct amount.
- Test amount conversion. A $25 top-up should credit $25.00, not $2,500. PSPs returning smallest currency unit is the most common bug source.
- Test with a gift. Grant a promotional credit using a generated idempotency key, then grant it again with the same key. The second call should be a no-op.
After the top-up: what the customer sees
Once the adjustment posts, the billing system takes over. The customer's wallet balance updates in real time. If you have integrated the billing system's customer portal, the new balance renders within the same second without a page reload. Usage events from that point onward deduct from the new balance atomically, so even if your product kicks off a high-volume job the moment the top-up completes, no request races the balance update.
Once posted, the billing system handles the rest:
- Live balance and history: the customer sees their balance and the top-up line item in the billing system's portal, embedded in your product under your brand
- Auto top-up: if the customer configured an auto-recharge threshold, the next near-depletion triggers another top-up flow through your PSP, with no manual intervention
- Usage blocking: when balance reaches zero, your policy decides whether to block, throttle, or allow overdraft. The portal surfaces the state to the customer before they hit the cutoff
The push model ends when the adjustment posts. Everything downstream (balance, portal, alerts, auto-recharge) is the billing system's job, not yours.
What you should not try to build yourself
The implementation surface for the push model is one outbound API call per payment event. Everything else (wallet ledger, balance display, top-up UI, auto-recharge logic, payment method management) belongs to the billing platform, not your codebase. If your implementation plan includes any of these, revisit which integration pattern you are using.
The cost of getting this wrong is documented. Engineering teams at Simplismart reported spending 20 to 30% of daily engineering bandwidth on homegrown billing indefinitely. Segwise abandoned a homegrown credit system after three weeks and rebuilt it on a managed platform in three days. At comparable revenue scale, Dropbox ran 50 to 70 engineers on billing infrastructure; OpenAI runs 1 to 1.5 on billing after moving to Metronome.
The reason is not the initial build. It is the nine subsystems teams underscope and then inherit as support tickets over the following eighteen months: metering, pricing engine, proration, invoice generation, credit wallets, entitlements, payment retry and dunning, subscription lifecycle, and revenue recognition. The push model gets you out of owning the credit wallet, the portal, and the adjustments ledger. Own the handler, not the ledger.
How Credyt handles PSP connections
Credyt's Adjustments API is POST /customers/:customerId/wallet/adjustments. The transaction_id parameter is the idempotency key; retries are safe by construction.
With Credyt, you can:
- Use
transaction_idas the idempotency key for at-least-once retries from any PSP webhook handler - Classify every adjustment with one of four reason codes (
external_topup,external_refund,gift,other) that map directly to the revenue-recognition treatments covered above - Resolve customers by
external_id, set once at customer creation, with no separate Credyt-ID lookup required in your hot path - Post adjustments in USD, custom tokens, GPU hours, or any configured asset on multi-asset wallets
- Expect HTTP 201 with the adjustment ID on success, 400 or 422 on validation failures
Credyt fits teams that want the billing platform to reflect their PSP's source-of-truth events without ever handling cards or PSP credentials. See the Adjustments API reference for the full parameter schema.
Frequently asked questions
What if a payment succeeds but the adjustment fails to post?
The customer has paid but their wallet has not been credited. Your background job should retry the adjustment call with exponential backoff. After a threshold number of retries, stop and alert; do not retry indefinitely. Keep a log of unresolved adjustment failures and review it daily until the backlog is clear. Since the transaction ID is your idempotency key, any successful retry credits the wallet correctly without double-crediting, regardless of how many times the call was attempted.
Can I connect multiple PSPs to the same billing system?
Yes. Balance adjustments are PSP-agnostic; they accept a transaction ID and a reason code regardless of which processor generated them. If you process payments through two PSPs, both can post adjustments to the same billing system. The only requirement is that transaction IDs are unique across all sources; use the PSP name as a prefix if needed.
Do I need to handle currency conversion before posting an adjustment?
Yes, if your product uses custom assets. Balance adjustments do not apply exchange rates automatically; you post in the asset you want to credit. For more on the design choices around custom-asset billing, see billing AI products in custom units instead of dollars.
What is the difference between posting an adjustment and using the billing system's native top-up API?
The native top-up API initiates a payment through the billing system's built-in checkout; it triggers the payment itself. A balance adjustment reflects a payment that has already happened in your own system. To use a native top-up flow, you first complete onboarding for the billing platform's built-in payment processing, which connects your Stripe account. For teams that want to keep payment processing entirely in their own stack (or are on a non-Stripe PSP), the balance-adjustment pattern is the right path. For contrast with Stripe's native approach, see how Stripe's usage-based billing actually works.
How do I handle chargebacks?
A chargeback means the card network has reversed the charge; the customer has the money back. The wallet should be debited to match. Use the catch-all reason code rather than the refund code, since a chargeback is a forced network reversal. Include enough metadata to make the chargeback identifiable: PSP chargeback ID, original payment reference, date. If the customer has already spent the credited balance, their wallet will go negative; your overdraft policy determines how that is handled.
Can I post adjustments in custom units rather than USD?
Yes, provided the asset is configured in the billing system. If your product bills in tokens and a customer purchases 10,000 tokens for $10, post the adjustment in tokens directly rather than USD. The billing system credits the token account rather than the USD account. This is useful when your pricing is denominated in a custom unit and you want the wallet to reflect that unit natively.
Related resources
- How AI companies adopt real-time billing without replacing their stack. Three-stage progression (Shadow, Hybrid, Full Wallet Control) that frames where this integration fits.
- Revenue recognition for usage-based billing. When credits become revenue under ASC 606 and IFRS 15.
- How to implement consumption-based pricing. The pricing design work that sits upstream of this integration.
- Common issues with Stripe metered billing for AI products. Where the Stripe-native path breaks down and why teams move to the push model.
