Skip to main content

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/sdk and a Zkp2pClient for 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​

  1. Check that the extension is installed, connected and new enough for the platform.
  2. Register peer.onMetadataMessage() before opening the provider tab.
  3. Call peer.authenticate() with captureMode: 'buyerTee'.
  4. Select the row for the payment the user made.
  5. Build the Buyer TEE params from that row's params, adding index: row.originalIndex only for platforms that need it.
  6. 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​

MethodDescription
isAvailable(): booleantrue 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): voidOpens the provider tab and starts the capture
onMetadataMessage(callback: PeerMetadataMessageCallback): () => voidSubscribes to capture results; returns an unsubscribe function
openInstallPage(): voidOpens the Chrome Web Store listing

Authenticate Parameters​

ParameterRequiredTypeDescription
actionTypeYesstringProvider template action, such as transfer_venmo. See the platform table
platformYesstringProvider platform, such as venmo
captureModeYes, for onramps'buyerTee'Use buyerTee to verify a buyer payment
attestationServiceUrlYes, for onrampsstringhttps://attestation-service.zkp2p.xyz. The extension encrypts the session to this service and accepts only Peer's attestation service
attestationActionTypeNostring | nullAttestation action, when it differs from actionType
attestationPlatformNostring | nullAttestation 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 hidden is false and params is a flat object of strings, numbers or booleans.
  • Match on paymentId when the provider returns one; otherwise match the amount, currency and recipient shown to the user.
  • Use row.originalIndex as the provider index, never the row's position after filtering or sorting.
  • Build attestation params from row.params, not buyerTeeCapture.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.

PlatformplatformactionTypeAdd params.indexMinimum extension
Venmovenmotransfer_venmoYes0.6.3
Cash Appcashapptransfer_cashappYes0.6.3
Revolutrevoluttransfer_revolutYes0.6.3
Zelle: Bank of America, Chase, Citizelletransfer_zelle_bofa, transfer_zelle_chase, transfer_zelle_citiYes0.6.3
Wisewisetransfer_wiseNo0.6.3
PayPal personal, businesspaypaltransfer_paypal, transfer_business_paypalNo0.6.3
Monzomonzotransfer_monzoNo0.6.3
Chimechimetransfer_chimeNo0.6.3
Alipayalipaytransfer_alipayNo0.6.7
UPI through Amazon Payupitransfer_upi_amazonpayNo0.6.8
Monobank business accountsmonobanktransfer_monobankNo0.7.2
X Moneyxmoneytransfer_xmoneyNo0.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​

IssueFix
getState() returns needs_installAsk the user to install the Peer extension, then retry
getState() returns needs_connectionCall requestConnection() from a user action and stop if the user declines
No callback arrivesRegister 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 proofCheck that the row has params and the capture has encryptedSessionMaterial
Verification rejects the params schemaAdd 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.md

Help?​

For integration questions, join the Peer Telegram community. For help with a specific order, email support@peer.xyz.