Confirming payments
A payment is confirmed when verifyPayment returns paid for the request you stored. Do not trust a transaction hash, a checkout page status or a broadcaster status on its own.
What verifyPayment checks
The Curvy aggregator emits a PendingNotes event for every shield. Each note in it carries a noteId, the payment reference R, a viewTag, a vault token id and a net amount. The noteId is a hash of the owner, the net amount and the token. R and viewTag are chosen by whoever shields, and they are not part of noteId.
So a buyer can shield a small amount into a note they own and put your R on it. Matching R alone would accept that fake. verifyPayment accepts a note only when all of these hold:
- The event was emitted by Curvy’s aggregator on the request’s chain (
sdk.aggregatorAddress), or by theaggregatorAddressyou pass. - The note carries the stored request’s
RandviewTag. - Its
noteIdrecomputes from the storedownerHash, the note’s amount and its token. That proves the note is yours. - Its token is the vault token id of
request.token. - Its net amount is at least what
request.amountyields after Curvy’s fees at the shield block (see Fees). - The shield block has at least
confirmationsblocks, counting itself. - Only with
paidWhen: "committed": the note is in aCommittedNotesbatch whose block also hasconfirmationsblocks (see When a payment counts as paid).
verifyPayment is exported from @0xcurvy/payments-sdk/merchant and is Node-only, because it recomputes noteId with rs-core. initialize() returns a bound sdk.verifyPayment, which uses confirmations and paidWhen from the config. Neither can be overridden per call. For agent payments, createX402Merchant runs the same check in the background (see x402).
Calling it
Both calls start from the attempt’s stored payment record (see Getting started):
import { parsePaymentRecord, serializePaymentRecord } from "@0xcurvy/payments-sdk/merchant";
const record = parsePaymentRecord(attempt.record);With the hint from the return URL:
const result = await sdk.verifyPayment({
publicClient, // on sdk.chainId
request: record.payment.intent, // exactly what createPaymentRequest returned, as signed
txHash, // untrusted hint; only this transaction is examined
});Block confirmations are configured once on initialize({ confirmations: … }); you do not pass them again here. You do not pass the aggregator either: sdk.verifyPayment uses sdk.aggregatorAddress, Curvy’s aggregator on your environment’s network (see Networks), or the one you set in network.
Without a hint, from a background job:
const result = await sdk.verifyPayment({
publicClient,
request: record.payment.intent,
fromBlock: record.fromBlock, // block number stored when the request was created
});Write each result you accept back into the same value, serializePaymentRecord({ ...record, verification: result }), and keep it whole. Never let a later not_found overwrite a note already found.
Without txHash, the SDK scans aggregator PendingNotes logs from fromBlock to the latest block and keeps the notes that belong to the request (checks 1–3). Look-alike notes are skipped. It then judges token, amount and confirmations on each of them and reports the first note that is paid, else the first that is confirming. Only when none pays does it report the earliest match, as underpaid or wrong_token. With paidWhen: "shielded" this is simply the earliest note that pays. With paidWhen: "committed" a later note that is already committed wins over an earlier one still waiting for its batch, because the batch prover chooses which pending notes it commits first. With txHash the same rule applies to the notes inside that one transaction. fromBlock is required when txHash is omitted, and ignored when it is present.
The standalone verifyPayment({ ..., confirmations, paidWhen }) from /merchant takes confirmations and paidWhen directly. Its aggregatorAddress defaults to Curvy’s aggregator on request.chainId; on a chain the SDK does not know, pass it, or the call throws INVALID_INPUT.
When a payment counts as paid (paidWhen)
A payment goes through two stages on chain:
- Shielded. The buyer’s transaction puts the money into the Curvy vault as a note that only your keys can spend. From here the money is safe: the buyer cannot take it back and nobody else can spend it. But your wallet cannot spend it yet.
- Committed. Curvy’s batch prover adds pending notes to the notes tree in batches, and the aggregator emits
CommittedNotes. From this point your wallet can spend the note.
paidWhen picks the stage at which verifyPayment says paid:
paidWhen | paid means | Pick it when |
|---|---|---|
"shielded" (default) | The note has confirmations blocks. The money is safe in the vault. | You ship goods or grant access. This is the fastest answer and enough for most shops. |
"committed" | Also: the note is in a CommittedNotes batch whose block also has confirmations blocks. The money is spendable. | You spend or forward the money right after the sale (pay a supplier, move it to an exchange), or you want paid to mean “in my spendable balance”. |
With "committed" a payment stays confirming after it has enough confirmations, until the batch prover commits it. How long that takes depends on the prover, not on your settings, so let your UI and your reconciler treat confirming as a normal wait.
underpaid, wrong_token and not_found do not depend on paidWhen. payment.committed is reported in both modes.
In a later protocol version (v4), "committed" will likely become required. Code that already handles a long confirming stage will not need to change.
const sdk = initialize({ /* ... */ confirmations: 12, paidWhen: "committed" });Statuses
verifyPayment returns { status, payment }. payment is null only when the status is not_found.
status | Meaning | What to do |
|---|---|---|
not_found | No matching note yet. With a txHash, the transaction is still pending. | Keep polling. |
confirming | The right note, token and amount, but fewer than confirmations blocks so far, or (with paidWhen: "committed") not yet committed | Show “confirming” and poll. |
paid | The right note, token and amount, with enough confirmations (and, with paidWhen: "committed", committed) | Fulfil the order once. Record payment.txHash, noteId and netAmount. |
underpaid | The note is yours, but netAmount < minimumNetAmount | Do not fulfil. Resolve it with the buyer manually. |
wrong_token | The note is yours, but in a different vault token | Do not fulfil. Resolve it with the buyer manually. |
underpaid and wrong_token are reported as soon as the note is seen, before it has enough confirmations. A reorg can still remove the note, so keep reconciling until you close the order.
payment is a VerifiedPayment:
| Field | Meaning |
|---|---|
txHash, blockNumber | The shield transaction |
confirmations | latest − blockNumber + 1 when checked (0n if your RPC is behind) |
noteId | The note that pays this request. Spend this one (see One spendable note per request). |
vaultTokenId | Vault token id of the note, not the ERC-20 address |
netAmount | Amount credited to your note, after fees |
minimumNetAmount | The net amount a gross payment of request.amount yields |
portalShield | true when the note came through a Curvy portal (human checkout) |
committed | Whether the note is already in a CommittedNotes batch, which makes it spendable in your wallet. paid needs it only with paidWhen: "committed". |
siblingNoteIds | Other notes this check saw that share this request’s ownerHash. For information only; paid does not depend on it. |
One spendable note per request
Every note under one ownerHash has the same nullifier, so only one of them can ever be spent. A second note to the same request, such as a buyer’s mistaken extra transfer or a dust note someone else shields with your public ownerHash, R and viewTag, competes with the one that paid you. Spend the verified payment.noteId. If siblingNoteIds is not empty, escalate: do not let your wallet spend a sibling first. The list covers only what this check saw; a sibling can still be shielded later.
Errors
When the SDK cannot produce a status, it throws PaymentVerificationError with a code:
code | Meaning | What to do |
|---|---|---|
TX_NOT_FOUND | Your RPC does not know the hinted transaction | Your RPC may lag behind checkout’s. Retry for a short while, then drop the hint and rely on the scan. |
REVERTED | The hinted transaction reverted | Reject the hint. |
NOT_A_SHIELD | The hinted transaction emitted no PendingNotes from your aggregator | Reject the hint. |
UNRELATED | The transaction shielded notes, but none pays this request | Reject the hint and do not poll it again. The order keeps reconciling. |
WRONG_CHAIN | publicClient is not on request.chainId | Fix your configuration. |
MISSING_FROM_BLOCK | Neither txHash nor fromBlock was given | Store fromBlock when you create the request. |
INVALID_INPUT | Malformed request, address, hash, confirmations, paidWhen or fromBlock, or no aggregatorAddress on a chain the SDK does not know | Fix the caller. A stored request that no longer parses was altered, or was stored before the stricter parser. |
Other errors, such as RPC failures, are thrown as plain errors, not PaymentVerificationError. Retry them. A failed read at the shield block or a failed eth_getLogs names the block or block range in its message, with the RPC error as cause.
verifyPayment reads the vault and aggregator (curvyVault, getTokenId, depositFee, perTokenGasFees, portalFactory) at the shield block. Your RPC must serve contract state at that block. A pruned (non-archive) node keeps only recent state, which on a fast L2 such as Arbitrum can be minutes. Verifying a payment older than that fails on every retry, with an error saying the RPC must serve contract state at the shield block (the node’s own error, such as missing trie node, is the cause). Use an archive RPC, or at least verify each payment soon after it is shielded.
Reconciliation
The return URL is a convenience. Your backend must work correctly without it:
- On return: your confirmation page posts
txHashto your backend, which callsverifyPaymentwith the stored request. If the response is not final, the page should poll your order API. Do not show “expired” while an attempt is still being reconciled. - In the background: a job, such as a timer or a queue worker, lists unfinished attempts and calls
verifyPayment({ ..., fromBlock })for each one. This completes orders whose buyer closed the tab. - Idempotent: once an attempt is
paid, stop checking it and never fulfil twice. This also keeps log queries and historical reads off old blocks. Several attempts on one order are verified separately. - When to stop: after
expiryCurvy no longer registers or shields a payment for that request. A shield sent just beforeexpirycan land a few blocks after it, so keep scanning until a short grace period afterexpiry, then close the attempt as unpaid. - RPC limits: many providers cap
eth_getLogsblock ranges, and the scan is oneeth_getLogscall fromfromBlockto the latest block. KeepfromBlockrecent (the block when the request was created), stop polling once an attempt ispaid, and stop scanning closed attempts. Thecommittedflag also queries logs from the shield block to the latest block, so re-checking an old payment can reach that cap. Old payments also need an archive RPC (see Errors).
Fees and net amount
The buyer sends exactly amount. The Curvy vault takes its fees from that amount, and your note holds the rest:
netAmount = amount
− amount × depositFee / 10000
− pendingNoteCommitment
− portalDeployment (portal shields only, which is every human checkout)depositFeeis in basis points:vault.depositFee().pendingNoteCommitmentandportalDeploymentare per-token gas fees, in token units:vault.perTokenGasFees(tokenId).vaultisaggregator.curvyVault().
verifyPayment reads these values at the shield block and returns the result as minimumNetAmount. It is floored at 1.
On Arbitrum One, a payment made on another network may arrive short by what bridging it cost, so minimumNetAmount is computed from amount less 3% of it (ROUTED_PAYMENT_TOLERANCE_BPS). payment.shortfall is how much less the note carries than the full amount would have yielded: the bridge cost you absorbed (0 when paid in full). payment.token is which of the request's tokens it arrived in. To quote a price before the sale, use readChainFees and quotePayment({ …, rail: "portal" }) from @0xcurvy/payments-sdk/economics (see Fees and minimum amounts).
Consequences for pricing:
- Set
amountto the gross price the buyer pays. You receive less. The fixed gas fees make up a larger share of small orders. - Curvy’s broadcaster refuses to shield portals worth less than USD 0.50, and it checks only after the buyer has paid. Never create a smaller request (see Fees and minimum amounts).
- Fees are read at the shield block. If Curvy changes them after you created the request, the minimum follows the new fees.
- A buyer who sends more than
amountstill getspaid: Curvy shields exactlyamount, and the rest stays at the payment address for the buyer to take back on checkout. - The percentage fee is rounded down. In rare cases a buyer who sends one base unit less than
amountgets the same net amount, so the payment passes.
Discovery hints (/chain)
findNoteInReceipt(receipt, [ephemeralKeyX, ephemeralKeyY], aggregatorAddress) from /chain is browser-safe. It returns { noteId, netAmount, token } or null, where token is the vault token id (a bigint), not the ERC-20 address. It matches only R, so it is a discovery hint and does not prove you were paid. The checkout page uses it to know when to redirect. Your backend must use verifyPayment.
Build the return URL yourself
To build the same URL checkout opens after a shield:
import { buildCheckoutCompleteUrl } from "@0xcurvy/payments-sdk/transport";
const url = buildCheckoutCompleteUrl(request, shieldTxHash);
// https://shop.example/checkout/complete#txHash=0x…Upgrading from 0.1.x
Moving from 0.1.x to 0.2.0-rc.3 brings these changes, and then those in Upgrading from 0.2.0-rc.1 and Upgrading from 0.2.0-rc.2:
verifyPaymentmoved from/chainand the root entry to/merchant.PaymentVerifyClientmoved with it, and it now also needsgetChainIdandreadContract. A viemPublicClienthas all of these.- It takes the stored
requestin place ofephemeralKey, and it returns{ status, payment }instead of a boolean. fromBlockis required when you omittxHash. The old no-hash path scanned from block0and waited forCommittedNotes; passpaidWhen: "committed"to wait for the commit again.parsePaymentIntentnow rejects out-of-range numbers and non-canonical decimals. Requests fromcreatePaymentRequeststill parse.
Upgrading from 0.2.0-rc.1
Moving from 0.2.0-rc.1 to 0.2.0-rc.2:
initializetakes a requiredenvironment: "mainnet" | "testnet"in place ofchainId."mainnet"is Arbitrum One (42161) and"testnet"is Ethereum Sepolia (11155111). For any other chain, passnetwork: { chainId, aggregatorAddress }(see Other networks).- Requests take
tokens, a list of symbols such as"USDC"or addresses, set once oninitializeor persdk.createPaymentRequest. By default a request takes every token Curvy takes on the network: USDC and USDT on mainnet, USDC on testnet. A request with more than one token lists them inintent.tokensand is signed asMultiTokenPaymentIntent.sdk.tokensreplacessdk.token. - On Arbitrum One, buyers can also pay in one of your tokens on another network, and
verifyPaymentcounts a payment up to 3% short as paid (see Tokens and other networks). EveryVerifiedPaymentnow hasshortfallandtoken. Payment records saved before still read, withshortfall0 andtokennull. aggregatorAddressis optional onsdk.verifyPaymentand on the standaloneverifyPayment. It defaults to Curvy’s aggregator on the request’s chain, and is required only on a chain the SDK does not know.buildCheckoutUrl(signed)uses Curvy’s checkout page,https://app.curvy.box/checkout(CURVY_CHECKOUT_URL).buildCheckoutUrl(checkoutUrl, signed)still works for another page.readChainFeestakeschainIdin place ofvaultAddresson Curvy’s networks.createX402Merchanttakestokenas a symbol or an address, and on Curvy’s networks uses the SDK’s own contract addresses before the broadcaster’s.
Upgrading from 0.2.0-rc.2
Moving from 0.2.0-rc.2 to 0.2.0-rc.3 changes x402 only:
createX402Merchanttakestokensin place oftoken: symbols or addresses, by default USDC and USDT on Arbitrum One.tokenDomains(by address) replacestokenDomain, and Curvy's tokens have their EIP-712 domains built in.X402Merchant.tokensreplacestoken,tokenIdandtokenDomain.- New, optional:
otherNetworks, to also take USDC on Base, Ethereum, Optimism, Polygon or Linea, bridged to Arbitrum One (see Taking payments on other networks). - x402 payments made on your own network must arrive in full. rc.2 counted them paid up to 3% short on Arbitrum One.
verifyPaymenthasallowBridgeShortfall(defaulttrue) for the same choice in your own code.