Save the merchant’s request first
A payment request binds a request ID, network/genesis, recipient, asset, atomic amount, reference, creation time and expiry. Persist the whole normalized request on the trusted merchant side before presenting its URI. A customer may supply a candidate signature and invoice ID; they must not replace the saved amount, recipient, reference, network or RPC endpoint.
The portable helper and Compass construct capp.solana-payment-request.v1. This complete helper makes no network request or wallet action. Copy solana-payments.mjs beside it; call it once when creating an invoice, then save the returned document. Inputs below belong to your application, not an uploaded receipt.
import {paymentRequest, paymentReference, parseAmount} from './solana-payments.mjs';
export async function createDevnetInvoice({requestId, recipient, amountText, createdAt}) {
if (!Number.isSafeInteger(createdAt) || createdAt <= 0) throw new Error('Use creation time in Unix seconds');
return paymentRequest({
requestId, network: 'devnet', recipient,
asset: {kind: 'native', decimals: 9}, amountAtomic: parseAmount(amountText, 9),
reference: paymentReference(), createdAt, expiresAt: createdAt + 900,
label: 'Example service', memo: requestId
});
}createdAt is Math.floor(Date.now() / 1000) at creation, not milliseconds. Retrying creation would choose another reference, so use your application's unique invoice transaction to create-or-load the saved request. The helper itself does not persist it or make that operation idempotent. Labels/messages are presentation; a digest detects changed terms but is not an issuer signature.
Display the network and expiry beside the QR/link. The standard Solana Pay transfer URI does not encode those cApp constraints. A receipt may be checked after expiry, but the actual observed block time must fall within the saved validity window. A late chain payment is not reversed because the verifier rejects it.
Verification through Witness
The owning application can use Witness with Conduit to check a candidate signature against its trusted saved request. Copy solana-client.mjs beside this module. It performs no work until called and does not send a payment.
import {createSolanaClient} from './solana-client.mjs';
const client = createSolanaClient(() => window.capp?.solana);
export async function inspectInvoicePayment(trustedSavedRequest, signature) {
const status = await client.call('witness', 'status');
if (status.ready !== true || status.providerMode !== 'conduit-peer') {
throw new Error('Configure Witness with Conduit for payment verification');
}
return client.call('witness', 'verifyPayment', {
paymentRequest: trustedSavedRequest, signature, commitment: 'finalized'
});
}Witness obtains signature status, transaction bytes and metadata through the configured RPC and checks them against the request, including actual recipient credit. An absent/pending/failed transaction is not a paid invoice. Querying activity for the saved reference can find candidate signatures; discovery alone does not verify a candidate.
This example deliberately requires finalized. A product choosing confirmed must explicitly own that different policy and its consequences. RPC spells the value finalized; Edge/Witness operation states can use finalised. Do not compare those fields as if they were the same enum.
A successful client-side result can inform the UI, but a remote merchant must repeat verification at its own trusted boundary. Never accept a browser's paid: true, uploaded receipt or user-selected RPC response as the service's evidence.
Verification on your own backend
verifyPaymentTransaction() is network-free. It validates trusted request terms against supplied signature/bytes, unmodified RPC metadata, slot, block time, observed genesis and confirmation status. It does not fetch or authenticate the observations. The following complete helper makes that service boundary explicit, following the supplied merchant SDK example.
import {paymentRequest, verifyPaymentTransaction} from './solana-payments.mjs';
export async function verifyMerchantInvoice({invoiceId, signature, loadInvoice, rpc}) {
if (typeof invoiceId !== 'string' || !invoiceId.trim() || invoiceId.length > 256 ||
typeof signature !== 'string' || !/^[1-9A-HJ-NP-Za-km-z]{64,88}$/.test(signature) ||
typeof loadInvoice !== 'function' || typeof rpc !== 'function') {
throw new Error('Provide an invoice identity, signature and trusted service dependencies');
}
const invoice = await loadInvoice(invoiceId); // Already scoped to the authorised merchant/tenant.
if (!invoice) throw new Error('Invoice not found');
const request = await paymentRequest(invoice.paymentRequest);
if (request.requestId !== invoiceId) throw new Error('Invoice and saved request differ');
const genesisHash = await rpc(request.network, 'getGenesisHash', []);
if (genesisHash !== request.genesisHash) throw new Error('Wrong payment network');
const statuses = await rpc(request.network, 'getSignatureStatuses', [
[signature], {searchTransactionHistory: true}
]);
const observed = statuses?.value?.length === 1 ? statuses.value[0] : null;
if (!observed || observed.err !== null || observed.confirmationStatus !== 'finalized') {
throw new Error('Successful finalised payment has not been established');
}
const transaction = await rpc(request.network, 'getTransaction', [signature, {
encoding: 'base64', commitment: 'finalized', maxSupportedTransactionVersion: 0
}]);
if (!transaction || transaction.slot !== observed.slot) throw new Error('Matching transaction is unavailable');
return verifyPaymentTransaction({
paymentRequest: request, signature, genesisHash, commitment: 'finalized',
confirmationStatus: observed.confirmationStatus, transaction: transaction.transaction,
meta: transaction.meta, slot: transaction.slot, blockTime: transaction.blockTime
});
}loadInvoice must read the authenticated merchant's own durable state, and rpc must be your bounded, configured JSON-RPC client returning result, not the whole JSON-RPC envelope. Authenticate the request and apply resource authorization before this helper. Keep service credentials, endpoint selection, rate/time/response bounds and retry policy at the service boundary. Do not accept these dependencies from the caller.
The result includes paid: true, the request digest, received atomic amount, signature, transaction/evidence digests and observed slot/commitment. It also states authorises: false and fulfilmentRecorded: false. Its trust depends on the RPC observations you supplied; this is not a light-client consensus proof.
Claim and deliver atomically
After verification, use a durable database transaction to recheck that the stored request digest is unchanged, claim the payment for the intended invoice and insert one fulfilment/outbox job. Require a unique claim for the network/signature in the scope of your service, so it cannot pay a second invoice. A repeated call for the already-matching claim returns that same recorded result; conflicting claims are rejected.
Deliver outside that database transaction through a durable worker with an idempotent downstream effect or reconciliation. After a crash, resume the existing job rather than creating another delivery. A unique job row alone cannot prevent duplication at a non-idempotent external service if its response disappears.
The Sidecars and payment helper do not implement your merchant database, authentication, shared claim or outbox. An in-memory Set, app localStorage, the generic SDK idempotency helper or a Lab report is not an atomic multi-device fulfilment boundary. Refunds need their own reviewed request and evidence; a rejection is not a chain reversal.
Supported verification scope
The included verifier supports native SOL and the original SPL Token Program within its defined instruction subset, including supported transfer/associated-account/memo and bounded compute-budget forms. Legacy messages and supported v0 messages without address lookup tables are accepted; unsupported instructions, lookup tables, Token-2022 and wrapped SOL are rejected.
For tokens, bind the exact mint, program and decimals. Verify the expected recipient account identity and balance increase, not a friendly symbol, a log string or an instruction amount alone. Self-transfers and zero/incorrect recipient credit must not satisfy an invoice. Missing block time or insufficient confirmation leaves evidence incomplete.
Verify your receiving integration
Test a valid credited payment, wrong recipient, wrong amount/reference/mint, altered saved terms, wrong genesis, insufficient commitment, failed or missing transactions, mismatched slots, expired-window credit and duplicate/concurrent claims. Test a service crash after payment claim and after downstream delivery separately. Cryptographic and controlled-RPC fixtures do not qualify live providers or your merchant database.
Use payment types and downloads, Witness and recovery for the underlying contracts. Keep public diagnostics bounded and free of private request data.