Onramp Integration
Use the Peer extension to verify a buyer's fiat payment in your desktop web app. Your app owns the order UI, the intent, payment-row selection and the fulfillIntent() call. The extension opens the payment platform in a tab, captures the payment rows, encrypts the session for the attestation service, and returns the result to your page. It keeps no capture data after the capture ends.
Prerequisites
@zkp2p/sdkand aZkp2pClientfor Base- The Peer extension, installed and connected for your origin, at the minimum version for the platform
- The intent hash of an intent your app signaled
End-to-end flow
- Check that the extension is installed, connected and new enough for the platform.
- Register
peer.onMetadataMessage()before opening the provider tab. - Call
peer.authenticate()withcaptureMode: 'buyerTee'. - Select the row for the payment the user made.
- Build the Buyer TEE params from that row's
params, addingindex: row.originalIndexonly for platforms that need it. - Pass the proof to
client.fulfillIntent().
client.fulfillIntent() reads the intent, posts the capture to the attestation service, encodes the returned PaymentAttestation, and sends the fulfillment transaction.
Getting the intent hash
Decode the IntentSignaled event from the signalIntent() receipt. Don't take the latest entry from getIntents(): an account can hold several open intents and read order is not guaranteed, so you could fulfill the wrong one.
import { createPublicClient, http, parseAbi, parseEventLogs } from "viem";
import { base } from "viem/chains";
const publicClient = createPublicClient({ chain: base, transport: http() });
// signalIntent() returns the transaction hash; see the Client Reference for params.
const txHash = await client.signalIntent({
/* ... */
});
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
const [intentSignaled] = parseEventLogs({
abi: parseAbi([
// OrchestratorV3
"event IntentSignaled(bytes32 indexed intentHash, address indexed escrow, uint256 indexed depositId, bytes32 paymentMethod, address owner, address to, uint256 amount, bytes32 fiatCurrency, uint256 conversionRate, uint256 timestamp)",
// OrchestratorV4
"event IntentSignaled(bytes32 indexed intentHash, address indexed escrow, uint256 indexed depositId, bytes32 paymentMethod, address owner, address to, uint256 amount, bytes32 fiatCurrency, uint256 conversionRate, uint256 timestamp, bytes32 groupId, address arbitrator)",
]),
logs: receipt.logs,
eventName: "IntentSignaled",
});
if (!intentSignaled) throw new Error("IntentSignaled event not found");
const intentHash = intentSignaled.args.intentHash;
Store the hash with your order. You need it to resume an interrupted flow, and client.getIntent(intentHash) reads the intent back.
Quickstart
This example verifies a Venmo payment. Venmo needs the selected row's originalIndex as params.index.
import {
createPeerExtensionSdk,
Zkp2pClient,
type BuyerTeePaymentProofInput,
type PeerBuyerTeePaymentCapture,
type PeerMetadataMessage,
type PeerMetadataRow,
} from "@zkp2p/sdk";
type BuyerTeePlatformConfig = {
platform: string;
actionType: string;
includeMetadataIndex: boolean;
minExtensionVersion: string;
};
const VENMO: BuyerTeePlatformConfig = {
platform: "venmo",
actionType: "transfer_venmo",
includeMetadataIndex: true,
minExtensionVersion: "0.6.3",
};
const ATTESTATION_SERVICE_URL = "https://attestation-service.zkp2p.xyz";
const peer = createPeerExtensionSdk({ window });
function isAtLeast(version: string, minimum: string): boolean {
const current = version.split(".").map(Number);
const required = minimum.split(".").map(Number);
for (let i = 0; i < 3; i += 1) {
const a = current[i] ?? 0;
const b = required[i] ?? 0;
if (a !== b) return a > b;
}
return true;
}
function isBuyerTeeParams(
value: unknown,
): value is Record<string, string | number | boolean> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.values(value).every(
(entry) =>
typeof entry === "string" ||
typeof entry === "number" ||
typeof entry === "boolean",
)
);
}
async function ensurePeerReady(minimumVersion: string) {
const state = await peer.getState();
if (state === "needs_install") {
peer.openInstallPage();
throw new Error("Install the Peer extension, then retry");
}
if (state === "needs_connection" && !(await peer.requestConnection())) {
throw new Error("Peer extension connection was not approved");
}
const version = await peer.getVersion();
if (!isAtLeast(version, minimumVersion)) {
throw new Error(
`Peer extension ${minimumVersion} or newer is required; found ${version}`,
);
}
}
function selectPaymentRow(
rows: PeerMetadataRow[],
expected: {
amount?: string;
currency?: string;
paymentId?: string;
recipient?: string;
},
): PeerMetadataRow | null {
const visibleRows = rows.filter(
(row) => !row.hidden && isBuyerTeeParams(row.params),
);
if (expected.paymentId) {
const byPaymentId = visibleRows.find(
(row) => row.paymentId === expected.paymentId,
);
if (byPaymentId) return byPaymentId;
}
return (
visibleRows.find(
(row) =>
(!expected.amount || row.amount === expected.amount) &&
(!expected.currency || row.currency === expected.currency) &&
(!expected.recipient || row.recipient === expected.recipient),
) ?? null
);
}
function buildBuyerTeeProof(
row: PeerMetadataRow,
capture: PeerBuyerTeePaymentCapture | null | undefined,
config: BuyerTeePlatformConfig,
): BuyerTeePaymentProofInput {
if (!capture?.encryptedSessionMaterial || !isBuyerTeeParams(row.params)) {
throw new Error("Selected payment row is missing Buyer TEE capture data");
}
return {
proofType: "buyerTee",
encryptedSessionMaterial: capture.encryptedSessionMaterial,
params: {
...row.params,
...(config.includeMetadataIndex ? { index: row.originalIndex } : {}),
},
actionPlatform: config.platform,
actionType: config.actionType,
};
}
export async function verifyOnrampPayment({
client,
expectedPayment,
intentHash,
}: {
client: Zkp2pClient;
expectedPayment: {
amount?: string;
currency?: string;
paymentId?: string;
recipient?: string;
};
intentHash: `0x${string}`;
}) {
await ensurePeerReady(VENMO.minExtensionVersion);
const unsubscribe = peer.onMetadataMessage(
async (message: PeerMetadataMessage) => {
try {
if (message.errorMessage) {
throw new Error(message.errorMessage);
}
const selectedRow = selectPaymentRow(message.metadata, expectedPayment);
if (!selectedRow) {
throw new Error(
"No returned payment row matched the expected payment",
);
}
await client.fulfillIntent({
intentHash,
proof: buildBuyerTeeProof(
selectedRow,
message.buyerTeeCapture,
VENMO,
),
attestationServiceUrl: ATTESTATION_SERVICE_URL,
});
} catch (error) {
console.error("Buyer TEE onramp fulfillment failed:", error);
} finally {
unsubscribe();
}
},
);
peer.authenticate({
actionType: VENMO.actionType,
attestationServiceUrl: ATTESTATION_SERVICE_URL,
captureMode: "buyerTee",
platform: VENMO.platform,
});
}
Peer Extension SDK API
The SDK exports a default instance and a factory. Use the factory in tests, iframes, or anywhere you want to avoid module-level browser access.
import {
peerExtensionSdk,
createPeerExtensionSdk,
isPeerExtensionAvailable,
getPeerExtensionState,
openPeerExtensionInstallPage,
PEER_EXTENSION_CHROME_URL,
} from "@zkp2p/sdk";
Methods on PeerExtensionSdk
| Method | Description |
|---|---|
isAvailable(): boolean | true when window.peer is injected |
getState(): Promise<'needs_install' | 'needs_connection' | 'ready'> | Install and origin connection state |
requestConnection(): Promise<boolean> | Asks the user to approve your origin. Peer domains and localhost are already connected |
checkConnectionStatus(): Promise<'connected' | 'disconnected' | 'pending'> | Connection status for the current origin |
getVersion(): Promise<string> | Installed extension version |
authenticate(params: PeerAuthenticateParams): void | Opens the provider tab and starts the capture |
onMetadataMessage(callback: PeerMetadataMessageCallback): () => void | Subscribes to capture results; returns an unsubscribe function |
openInstallPage(): void | Opens the Chrome Web Store listing |
Authenticate Parameters
| Parameter | Required | Type | Description |
|---|---|---|---|
actionType | Yes | string | Provider template action, such as transfer_venmo. See the platform table |
platform | Yes | string | Provider platform, such as venmo |
captureMode | Yes, for onramps | 'buyerTee' | Use buyerTee to verify a buyer payment |
attestationServiceUrl | Yes, for onramps | string | https://attestation-service.zkp2p.xyz. The extension encrypts the session to this service and accepts only Peer's attestation service |
attestationActionType | No | string | null | Attestation action, when it differs from actionType |
attestationPlatform | No | string | null | Attestation platform, when it differs from platform |
Metadata Row Selection
onMetadataMessage() returns the provider's rows and one encrypted Buyer TEE capture:
type PeerMetadataMessage = {
buyerTeeCapture?: {
encryptedSessionMaterial: string;
params?: Array<Record<string, string | number | boolean>>;
} | null;
errorMessage?: string;
expiresAt: number;
metadata: Array<{
amount?: string;
currency?: string;
date?: string;
hidden: boolean;
originalIndex: number;
params?: Record<string, string | number | boolean>;
paymentId?: string;
recipient?: string;
[key: string]: unknown;
}>;
platform: string;
requestId: string;
};
- Select a row where
hiddenisfalseandparamsis a flat object of strings, numbers or booleans. - Match on
paymentIdwhen the provider returns one; otherwise match the amount, currency and recipient shown to the user. - Use
row.originalIndexas the provider index, never the row's position after filtering or sorting. - Build attestation params from
row.params, notbuyerTeeCapture.params.
Metadata Index Params
Some verifiers need the row's originalIndex as params.index to select the same row from the encrypted session. The others have strict schemas and reject an extra index.
| Platform | platform | actionType | Add params.index | Minimum extension |
|---|---|---|---|---|
| Venmo | venmo | transfer_venmo | Yes | 0.6.3 |
| Cash App | cashapp | transfer_cashapp | Yes | 0.6.3 |
| Revolut | revolut | transfer_revolut | Yes | 0.6.3 |
| Zelle: Bank of America, Chase, Citi | zelle | transfer_zelle_bofa, transfer_zelle_chase, transfer_zelle_citi | Yes | 0.6.3 |
| Wise | wise | transfer_wise | No | 0.6.3 |
| PayPal personal, business | paypal | transfer_paypal, transfer_business_paypal | No | 0.6.3 |
| Monzo | monzo | transfer_monzo | No | 0.6.3 |
| Chime | chime | transfer_chime | No | 0.6.3 |
| Alipay | alipay | transfer_alipay | No | 0.6.7 |
| UPI through Amazon Pay | upi | transfer_upi_amazonpay | No | 0.6.8 |
| Monobank business accounts | monobank | transfer_monobank | No | 0.7.2 |
| X Money | xmoney | transfer_xmoney | No | 0.7.6 |
Zelle proofs must pass actionPlatform: "zelle" and the bank's actionType.
Buyer TEE Attestation Endpoint
client.fulfillIntent() posts the capture to:
POST {attestationServiceUrl}/buyer/verify/{actionPlatform}/{actionType}
with this body:
{
encryptedSessionMaterial: proof.encryptedSessionMaterial,
params: proof.params,
chainId,
intent: {
intentHash,
amount,
timestampMs,
paymentMethod,
fiatCurrency,
conversionRate,
payeeDetails,
owner,
timestampBufferMs, // defaults to "300000"; override with fulfillIntent({ timestampBufferMs })
},
}
It then encodes the returned PaymentAttestation and fulfills the intent on-chain.
To sign and send the transaction yourself, call client.fulfillIntent.prepare() with the same arguments. It runs the same attestation request and returns the unsigned transaction { to, data, value, chainId }. To call only the attestation service, use apiVerifyBuyerTeePayment(body, attestationServiceUrl, actionPlatform, actionType).
Seller Credential Capture
The same bridge captures Cash App and Amazon Pay UPI seller credentials for Seller Autopilot. It returns only the encrypted credential bundle and the seller's offchainId, never plaintext session material.
peer.authenticate({
actionType: "transfer_cashapp",
captureMode: "sellerCredential",
platform: "cashapp",
});
For Amazon Pay UPI, pass platform: "upi", actionType: "transfer_upi_amazonpay" and the seller's wallet as callerAddress.
When message.sarCredentialCapture arrives, call client.uploadSellerCredentialBundle({ platform, offchainId, bundle }). It registers or recovers the payee through Peer's API, checks the returned payee hash against bundle.payeeIdHash, and stores the bundle. Venmo, PayPal and X Money sellers connect a receipt inbox instead; see Seller Autopilot.
Full Customization: Build Your Own Extension
Everything on this page except the capture runs in your app, so you can ship your own extension under your own brand. If it injects the same window.peer interface, this page's code works unchanged. See Build Your Own Extension.
Common Issues
| Issue | Fix |
|---|---|
getState() returns needs_install | Ask the user to install the Peer extension, then retry |
getState() returns needs_connection | Call requestConnection() from a user action and stop if the user declines |
| No callback arrives | Register onMetadataMessage() before authenticate(), and check that the provider tab reached the platform's activity page |
Session capture requires an attestation service URL. | Pass attestationServiceUrl on every buyerTee launch |
Attestation service URL must use a trusted ZKP2P endpoint. | Use https://attestation-service.zkp2p.xyz |
| The selected row can't build a Buyer TEE proof | Check that the row has params and the capture has encryptedSessionMaterial |
| Verification rejects the params schema | Add index only for the platforms marked Yes in the platform table |
LLM Integration Prompt
Download a ready-to-paste prompt that tells an AI coding assistant how to add this capture flow to your app:
Download onramp-llm.mdHelp?
For integration questions, join the Peer Telegram community. For help with a specific order, email support@peer.xyz.