Copperx is a stablecoin-based financial platform that combines payment links, invoices, APIs, virtual bank accounts, payouts, and corporate cards for businesses operating across borders. This article explains how to integrate a Copperx-style payment flow into your own backend, how to reconcile fiat-side events with on-chain USDC settlement, and how to verify transfers independently using an RPC endpoint. It separates documented platform behavior from the verification steps you must run yourself, and it shows where a reliable RPC layer fits into the architecture. The goal is a reproducible reconciliation procedure rather than a marketing overview.
Scope and the reconciliation problem
Copperx is described in public materials as a stablecoin-based neobank and payment platform: it offers a payment gateway for accepting stablecoins via links, invoices, and APIs, financial accounts denominated in USD, EUR, and AED, global payouts, and corporate cards. The Circle Alliance listing describes it as self-custodial, with USDC, Programmable Wallets, Paymaster, and CCTP among the products used, and lists Arbitrum, Base, Ethereum, OP Mainnet, and Polygon PoS among supported blockchains. Those are documented claims about the platform; they are not guarantees about your specific account configuration, corridor availability, or contract addresses.
The engineering problem this article addresses is narrower than the product surface. When a customer pays a stablecoin invoice, your backend receives a webhook or API event from the payment platform, and separately a transfer settles on a blockchain. Those two records must agree. If they disagree, you have either a customer who paid and was not credited, or a credit that has no on-chain backing. A reconciliation layer that independently verifies the on-chain leg is the difference between trusting a webhook and knowing the funds moved.
This guide assumes you already have a Copperx account or a comparable stablecoin gateway, a backend that can receive webhooks, and access to an RPC endpoint for each chain you accept. It does not assume any particular pricing, corridor, or chain support; confirm those in your account dashboard and the platform documentation before building.
- Documented platform behavior: payment links, invoices, APIs, virtual bank accounts, payouts, cards, self-custodial positioning.
- What you must verify yourself: that a specific transfer exists, reached the expected address, and matches the expected amount and chain.
- What this article does not claim: any measured latency, fee, success rate, or chain support beyond what the platform publishes.
Mental model: three ledgers that must agree
A stablecoin payment integration is a three-ledger problem. The first ledger is your application database: orders, invoices, customer identities, and the state machine that decides when an order is fulfilled. The second is the payment platform's ledger: the payment intent, its status, and the fiat or stablecoin balance movements it records. The third is the blockchain: the actual ERC-20 transfer of USDC from the payer to the receiving address.
Most integration bugs come from collapsing these into one. A webhook is an assertion by the platform's ledger, not proof of the blockchain ledger. A blockchain transfer is proof of settlement, but it does not tell you which customer or order it belongs to. Your job is to join them with a durable key. The cleanest key is the transaction hash plus the receiving address plus the chain ID, stored on the payment intent when the platform reports it. If the platform does not expose a transaction hash at intent creation, capture it at the first status event that includes it and treat the intent as unverified until then.
A second useful distinction is between the payment rail and the settlement rail. A customer may pay by card, bank transfer, or stablecoin; the platform may settle to you in USDC on one of several chains. The chain you verify is the settlement chain, not necessarily the chain the customer used. Read the platform's documentation for which chain a given account settles on, and verify that chain specifically.
Prerequisites and environment assumptions
Before writing reconciliation code, confirm four things. First, the exact API base URL and authentication scheme for your Copperx account, taken from the platform's developer documentation rather than from memory. Second, the webhook signing secret and the header name used for signatures. Third, the settlement chain and the USDC contract address on that chain, which you should copy from a canonical source such as Circle's USDC documentation rather than from a block explorer search result. Fourth, an RPC endpoint for that chain that you control or that comes from a provider with a documented SLA.
For the RPC layer, do not hardcode a public endpoint. Use an environment variable such as RPC_URL and point it at a managed endpoint. If you are choosing a provider, the RPC Assistant has concrete recommendations; for the methodology in this article, any endpoint that supports eth_getTransactionReceipt and eth_call is sufficient. OnFinality's managed RPC service and dedicated node options are reasonable places to start if you need predictable throughput across several chains.
- API base URL and auth scheme from Copperx developer docs.
- Webhook signing secret and signature header name.
- Settlement chain ID and canonical USDC contract address.
- RPC_URL for that chain, stored in environment configuration, never in source.
Creating a payment intent and storing the join key
The first integration step is to create a payment intent from your backend and persist the platform's identifier alongside your own order ID. The exact endpoint path and payload fields depend on the Copperx API version you are on; the shape below is illustrative and should be adapted to the documented schema. The important design decision is that your database row is created before the API call returns, so a webhook that arrives quickly still finds a row to update.
Store at minimum: your order ID, the platform payment intent ID, the expected amount and currency, the settlement chain ID, the expected receiving address, and a status field with an explicit unverified state. Do not mark an order paid on intent creation. Intent creation means the customer has been asked to pay, not that they have.
curl -X POST "$COPPERX_API_BASE/payment-intents" \
-H "Authorization: Bearer $COPPERX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": "149.00",
"currency": "USDC",
"reference": "order_1042",
"metadata": { "customer_id": "cus_88" }
}'Handling webhooks without double-crediting
Webhooks are delivered at least once in most payment systems, and Copperx-style platforms are no exception. Your handler must be idempotent. The standard pattern is to verify the signature, parse the event ID, and insert the event ID into a unique-constrained table before doing any work. If the insert conflicts, return 200 immediately and do nothing. This makes redelivery harmless.
After deduplication, update the payment intent row only if the incoming status is a forward transition. A late-arriving 'pending' event must not overwrite a 'paid' state. Keep a small state machine and reject backward transitions. Finally, when the event includes a transaction hash, write it to the row and enqueue a verification job. Do not perform the RPC call inside the webhook handler; webhook handlers should be fast and should not fail because an RPC endpoint is slow.
import hashlib, hmac, json
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = b"WEBHOOK_SIGNING_SECRET"
@app.post("/webhooks/copperx")
def copperx_webhook():
raw = request.get_data()
sig = request.headers.get("X-Copperx-Signature", "")
expected = hmac.new(WEBHOOK_SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
event = json.loads(raw)
event_id = event["id"]
if not db.insert_event_once(event_id):
return {"ok": True}, 200
intent = event["data"]
db.apply_forward_transition(
intent_id=intent["id"],
new_status=intent["status"],
tx_hash=intent.get("transaction_hash"),
)
if intent.get("transaction_hash"):
queue.enqueue("verify_settlement", intent["id"])
return {"ok": True}, 200Verifying settlement on-chain with an RPC call
The verification job takes the stored transaction hash, fetches the receipt, and checks three things: the transaction succeeded, the receipt contains a Transfer log from the expected USDC contract, and the log's recipient and amount match the intent. This is the step that converts a platform assertion into evidence.
Use eth_getTransactionReceipt for the hash. Check status equals 0x1. Then scan logs for the ERC-20 Transfer event topic, which is the keccak256 hash of Transfer(address,address,uint256). Decode the indexed from and to addresses and the non-indexed value. Compare the to address to your expected receiving address, case-insensitively after checksum normalization, and compare the value to the expected amount in the token's smallest unit. USDC uses six decimals, so 149.00 USDC is 149000000.
If the receipt is null, the transaction is not yet mined or the hash is wrong. Treat null as pending with a bounded retry, not as failure. If the receipt exists but the log does not match, treat it as a mismatch and route to manual review rather than auto-crediting.
const RPC_URL = process.env.RPC_URL;
const USDC = process.env.USDC_CONTRACT;
const TRANSFER_TOPIC =
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
async function verifySettlement(txHash, expectedTo, expectedAmount) {
const res = await fetch(RPC_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0", id: 1, method: "eth_getTransactionReceipt",
params: [txHash],
}),
});
const { result: receipt } = await res.json();
if (!receipt) return { state: "pending" };
if (receipt.status !== "0x1") return { state: "failed" };
const match = receipt.logs.find((log) =>
log.address.toLowerCase() === USDC.toLowerCase() &&
log.topics[0] === TRANSFER_TOPIC &&
"0x" + log.topics[2].slice(26) === expectedTo.toLowerCase()
);
if (!match) return { state: "mismatch" };
const value = BigInt(match.data);
if (value !== BigInt(expectedAmount)) return { state: "amount_mismatch" };
return { state: "verified", blockNumber: receipt.blockNumber };
}A results table for your own reconciliation run
Do not accept published performance numbers for your integration; measure your own. Run a small batch of test payments through your staging environment and record the following table. The columns are the minimum needed to diagnose where time is spent and where mismatches occur. Fill in your own values; the table below is a template, not a result.
| Test case | Intent created (UTC) | Webhook received (UTC) | Receipt available (UTC) | Verification state | Notes |
|---|---|---|---|---|---|
| Happy path, single chain | |||||
| Webhook redelivered twice | |||||
| Receipt null on first poll | |||||
| Amount mismatch injected | |||||
| Wrong receiving address |
The interesting rows are the failure rows. A correct integration should show 'verified' for the happy path, no double credit for redelivery, 'pending' then 'verified' for the null-receipt case, and 'amount_mismatch' or 'mismatch' for the injected faults. If any failure row shows 'verified', your matching logic is too loose.
Troubleshooting common reconciliation failures
The most common failure is a chain mismatch. The platform settles on one chain, your RPC_URL points at another, and the receipt is always null. Confirm the settlement chain from the platform's documentation and from the intent payload, and make the chain ID part of your stored intent so the verification job cannot use the wrong endpoint.
The second most common failure is decimal confusion. USDC has six decimals, but some tokens use eighteen. If you compare a human-readable amount to a raw log value, every payment will mismatch. Always convert to the token's smallest unit using the contract's decimals, and store amounts as integers.
The third is address normalization. Log topics are 32-byte words with the address left-padded; comparing the raw topic to a 20-byte address will never match. Slice the last 20 bytes, then compare case-insensitively. The fourth is a receipt that exists but the log is from a different contract that also emits Transfer events; always filter by the USDC contract address. The fifth is a reorg: a receipt can become invalid if the block is reorged out. For high-value payments, wait for a confirmation depth appropriate to the chain before final fulfillment.
- Chain mismatch: store chain ID with the intent and select RPC_URL by chain.
- Decimal mismatch: convert with the token's decimals, store integers.
- Address padding: slice topics to 20 bytes before comparing.
- Wrong contract: filter logs by the canonical USDC address.
- Reorg risk: require confirmations before final credit on high-value orders.
Limitations, tradeoffs, and what the platform does not solve
Independent verification adds latency and operational surface. You now depend on an RPC endpoint, you must handle its failures, and you must keep contract addresses and chain IDs current. The tradeoff is worth it for payments above a threshold you define; below that threshold, trusting the webhook may be acceptable. A common pattern is to auto-credit small amounts on webhook and require on-chain verification above a configurable limit.
There are also things the platform cannot verify for you. It cannot tell you whether your customer is sanctioned, whether your own accounting treatment of stablecoin receipts is correct in your jurisdiction, or whether the corridor you are using will be available next quarter. Those are compliance and treasury questions, not integration questions. Similarly, the platform's self-custodial positioning means key management is your responsibility where it applies; do not assume the platform holds your keys.
Finally, RPC reliability is not uniform across chains. A public endpoint may rate-limit you during a batch reconciliation, and a single provider may have degraded performance on one chain. If you settle on several chains, consider a multi-chain RPC setup and monitor per-chain error rates rather than a single aggregate.
Next steps: hardening and scaling the integration
Once the happy path and the failure rows in your results table behave correctly, harden the system in three directions. First, add a periodic sweep that re-verifies any intent stuck in 'pending' beyond a timeout, so a lost webhook does not leave an order unfulfilled. Second, add alerting on verification state transitions to 'mismatch' and on RPC error rates, because those are the signals that precede customer complaints. Third, document the settlement chain and contract address per corridor in a configuration file under version control, so a chain migration is a reviewed change rather than an incident.
For the RPC layer, move from a single endpoint to a managed service with documented limits, and keep a fallback endpoint configured. If you need dedicated throughput or private networking, a dedicated node is the appropriate step up from shared RPC. For chain-specific endpoint choices, consult the RPC Assistant rather than guessing; for the broader architecture, the multi-chain RPC endpoints guide and the blockchain node hosting guide cover the operational details this article deliberately leaves out.
- Add a pending-intent sweep with a timeout and re-verification.
- Alert on mismatch states and RPC error rates.
- Version-control chain and contract configuration per corridor.
- Move to a managed RPC service with a fallback before scaling volume.