Build Your Own Extension
The Peer extension is a thin capture bridge. It opens the payment platform in a tab, intercepts requests that match a provider template, extracts payment rows, encrypts session material for the attestation service, and posts the result to the page. Your app does everything else, including quotes, intents, fulfillment and seller registration, through @zkp2p/sdk.
You can replace the bridge with an extension under your own name, icon, Web Store listing and consent UI. If the Peer extension works for you as is, follow Onramp Integration instead.
Start from the open-source branded extension template: fork it, edit brand.config.json, run npm run rebrand, and keep the security requirements.
Architecture
| Component | Responsibility |
|---|---|
| Your extension | Opens the platform tab, intercepts template-matched requests, extracts rows, encrypts session material, and posts results to the page |
Your web app + @zkp2p/sdk | Quotes, signalIntent(), fulfillIntent(), seller registration and credential upload |
| Peer attestation service | Decrypts session material inside a TEE, re-verifies the payment or credential with the platform, and signs the result |
Plaintext session material (cookies, auth headers, request bodies) never leaves the extension. The extension encrypts it to the attestation service and returns only the encrypted blob and non-sensitive metadata rows.
The page contract
The Peer extension injects window.peer. If your extension implements the same interface, page code, including createPeerExtensionSdk() from @zkp2p/sdk, works unchanged:
interface Peer {
getVersion(): Promise<string>;
requestConnection(): Promise<boolean>;
checkConnectionStatus(): Promise<"connected" | "disconnected" | "pending">;
authenticate(params: PeerAuthenticateParams): void;
onMetadataMessage(
callback: (message: PeerMetadataMessage) => void,
): () => void;
}
authenticate() starts a capture:
| Parameter | Description |
|---|---|
actionType | Provider template action, such as transfer_venmo |
platform | Platform the template belongs to, such as venmo |
captureMode | buyerTee for buyer payments and identity registration, sellerCredential for Seller Autopilot |
attestationServiceUrl | Required for buyerTee. Optional for sellerCredential, where it defaults to the production service |
attestationActionType / attestationPlatform | Attestation route, when it differs from the template's actionType / platform |
callerAddress | Seller wallet address. Required for Amazon Pay UPI seller capture |
The result arrives as a PeerMetadataMessage with requestId, platform, expiresAt, the extracted metadata rows, and either errorMessage or one of:
buyerTeeCapture: { encryptedSessionMaterial, params? }for buyer and identity capturessarCredentialCapture: { credentialBundle, offchainId }for Seller Autopilot
See Metadata Row Selection for the row shape.
Provider templates
Templates tell the extension what to intercept and how to extract rows. Peer's API serves them at a fixed path:
https://api.zkp2p.xyz/providers/{platform}/{actionType}.json
For example, https://api.zkp2p.xyz/providers/venmo/transfer_venmo.json. The Peer extension loads every template from this path and accepts no template or template URL from the page. Your extension should do the same.
Template shape
{
"authLink": "https://provider.example/login",
"url": "https://provider.example/api/replay",
"method": "GET",
"body": "",
"metadata": {
"platform": "provider",
"method": "GET",
"urlRegex": "https://provider.example/api/transactions.*",
"bodyRegex": "",
"fallbackMethod": "GET",
"fallbackUrlRegex": "",
"fallbackBodyRegex": "",
"metadataUrl": "",
"metadataUrlMethod": "GET",
"metadataUrlBody": "",
"preprocessRegex": "",
"transactionsExtraction": {
"transactionJsonPathListSelector": "$.transactions",
"transactionJsonPathSelectors": {
"paymentId": "$.id",
"amount": "$.amount.value",
"currency": "$.amount.currency",
"date": "$.created_at",
"recipient": "$.recipient.name"
}
}
},
"paramNames": [],
"paramSelectors": []
}
| Field | Purpose |
|---|---|
authLink | Page opened in the capture tab |
metadata.platform | Platform name returned in the capture result |
metadata.method / urlRegex / bodyRegex | Primary request to intercept |
metadata.fallbackMethod / fallbackUrlRegex / fallbackBodyRegex | Fallback request to intercept |
url / method / body | Request replayed with the fallback request's headers when only the fallback matched |
metadata.metadataUrl / metadataUrlMethod / metadataUrlBody | Request replayed with the matched request's headers, instead of reading that request's response |
metadata.shouldReplayRequestInPage | Replay inside the provider tab instead of the extension |
metadata.preprocessRegex | Regex whose capture group 1 is parsed before extraction |
metadata.transactionsExtraction | JSONPath or XPath selectors that produce metadata rows |
metadata.userInput | Click guide for platforms where the user must open a transaction first |
metadata.shouldSkipCloseTab | Keep the provider tab open after a successful capture |
paramNames / paramSelectors | Public attestation params, see Buyer TEE params |
captureJsEnabled / captureJs | When true, the template ships sandboxed JavaScript capture code instead of selectors |
MV3 webRequest doesn't expose response bodies, so the Peer extension replays each matched request with its captured headers to read the response. Some templates set captureJsEnabled: true; the Peer extension runs their captureJs source in a sandbox. If your extension can't, leave those platforms out.
Metadata extraction
For JSON responses, use a list selector plus field selectors relative to each row:
{
"metadata": {
"transactionsExtraction": {
"transactionJsonPathListSelector": "$.activity_rows",
"transactionJsonPathSelectors": {
"paymentId": "$.id",
"amount": "$.amount.value",
"currency": "$.amount.currency",
"date": "$.created_at"
}
}
}
}
Omit transactionJsonPathListSelector to run the selectors against the root object and return one row. For HTML responses, use transactionXPathListSelector and transactionXPathSelectors. Each row carries the selected fields plus originalIndex and hidden; a row with a missing field is hidden.
When the user must click an element first, such as a transaction's detail view, the template includes metadata.userInput. transactionXpath is required:
{
"metadata": {
"userInput": {
"promptText": "Select the transaction to share.",
"transactionXpath": "//button[contains(., 'Details')]",
"waitForXpathMs": 8000,
"pollIntervalMs": 250
}
}
}
Buyer TEE params
paramNames and paramSelectors define the public params extracted for each row, alongside the encrypted session material:
{
"paramNames": ["PAYMENT_ID", "ACCOUNT_ID"],
"paramSelectors": [
{ "type": "jsonPath", "value": "$[{{INDEX}}].id" },
{ "type": "regex", "source": "url", "value": "account/([^/?]+)" }
]
}
type:jsonPath,regex, orxPath.source:responseBody(default),requestBody, orurl. Reject header selectors.{{INDEX}}is replaced with the row'soriginalIndex.requestBodyselectors are private. Never copy their values into the rows returned to the page.
Identity registration templates
Identity templates such as register_paypal, register_business_paypal, register_wise and register_alipay use the same shape and the buyerTee capture mode. The page sends the capture to the attestation service's identity endpoint instead of fulfillIntent(); see Identity attestation.
Develop a new platform
To add capture for a new payment platform, use the Provider builder. It guides you through capture, encrypted test replay and sharing. Pages can't pass templates to the Peer extension, so test against a published template or through the Provider builder. To get a provider published, ask on Telegram.
Implementing the buyer flow
The buyer flow turns a payment the user already made into encrypted session material for fulfillIntent(). It is template-driven: a new buyer platform needs a template, not extension code.
- Launch. The page calls
authenticate({ actionType, platform, captureMode: 'buyerTee', attestationServiceUrl }). Load the template and openauthLinkin a new tab. - Intercept. Register
chrome.webRequestlisteners (onBeforeRequest,onSendHeaders,onResponseStarted) forurlRegex,fallbackUrlRegexandmetadataUrl. Cache the matched request's URL, method, headers and body, then replay it to read the response. - Extract. Run
transactionsExtractionover the response to build rows, andparamSelectorsto build each row'sparams. The Peer extension usesjsonpath-plus. - Encrypt. Build session material from the captured request and encrypt it in the extension:
import { createEncryptedBuyerTeeSessionMaterial } from "@zkp2p/sdk";
const sessionMaterial: Record<string, string> = {
...capturedRequestHeaders, // header name -> value
...(capturedRequestBody ? { body: capturedRequestBody } : {}),
url: capturedRequestUrl,
};
const encryptedSessionMaterial = await createEncryptedBuyerTeeSessionMaterial({
platform: "venmo",
actionType: "transfer_venmo",
attestationServiceUrl, // from the launch payload
sessionMaterial,
});
- Return. Post
{ requestId, platform, metadata, expiresAt, buyerTeeCapture: { encryptedSessionMaterial, params } }to the document that launched the capture, then discard all capture state.
The page then builds { proofType: 'buyerTee', encryptedSessionMaterial, params } from the selected row and calls fulfillIntent(), as described in Onramp Integration, including the per-platform params.index rule.
MV3 service workers have no DOM, so run XPath extraction in an offscreen document. When you call the SDK encryption helpers outside a window, pass attestationRuntime: { fetch, subtle, getRandomValues } (type SellerCredentialAttestationRuntime).
Implementing the Seller Autopilot flow
Seller Autopilot lets a seller upload encrypted platform credentials once, so incoming payments verify and release automatically. Extension capture covers Cash App and Amazon Pay UPI. Venmo, PayPal and X Money sellers connect a receipt inbox, and Wise sellers use a personal API token; neither needs the extension. See Seller Autopilot for those flows.
The Seller Autopilot result contains only the encrypted credentialBundle and the seller's offchainId. Never post captured headers, cookies, payee IDs or session material to the page.
- Launch. The page calls
authenticate()withcaptureMode: 'sellerCredential':- Cash App:
actionType: 'transfer_cashapp',platform: 'cashapp' - Amazon Pay UPI:
actionType: 'transfer_upi_amazonpay',platform: 'upi', plus the seller'scallerAddress
- Cash App:
- Capture. Use the same template-driven interception as the buyer flow.
- Parse. Build the platform's upload payload from the captured request, in memory. This is the only platform-specific code in the extension:
| Platform | Payload | offchainId |
|---|---|---|
| Cash App | { payeeId: cashtag, sessionMaterial: { recipientCashtag, customerId, sessionCookie, requestPayload } } | The cashtag |
| Amazon Pay UPI | { callerAddress, sessionMaterial: { sessionCookie, userAgent } }, with no payeeId | getAmazonPaySellerIdentity(credentialBundle) |
- Bundle. Create the encrypted credential bundle through the attestation service:
import {
apiCreateSellerCredentialBundle,
type SellerCredentialAttestationRuntime,
} from "@zkp2p/sdk";
const attestationRuntime: SellerCredentialAttestationRuntime = {
fetch: globalThis.fetch.bind(globalThis),
subtle: globalThis.crypto.subtle,
getRandomValues: globalThis.crypto.getRandomValues.bind(globalThis.crypto),
};
const response = await apiCreateSellerCredentialBundle(
payload, // from step 3
attestationServiceUrl,
platform,
undefined, // timeoutMs
attestationRuntime,
);
const credentialBundle = response.responseObject;
- Return. Post
{ requestId, platform, metadata: [], expiresAt, sarCredentialCapture: { credentialBundle, offchainId } }to the page and discard everything else.
The page finishes registration. Your extension must not call Peer's API or create makers:
await client.uploadSellerCredentialBundle({
platform: "cashapp",
offchainId: capture.offchainId,
bundle: capture.credentialBundle,
});
// Then read status with client.getSellerCredentialStatus().
uploadSellerCredentialBundle() registers or recovers the payee through Peer's API (POST /v2/makers/create), checks that the returned hashedOnchainId equals bundle.payeeIdHash, then stores the bundle (POST /v2/makers/{platform}/{hashedOnchainId}/seller-credential). Keep that hash check if you make these calls yourself: it stops a tampered capture from binding credentials to the wrong payee.
SDK reference for extension builders
| Export | Flow | Use |
|---|---|---|
createEncryptedBuyerTeeSessionMaterial({ platform, actionType, attestationServiceUrl, sessionMaterial }) | Buyer, identity | Encrypt captured session material to the attestation service |
createNitroAttestationClient({ environment, attestationServiceUrl }) | Identity | Request typed identity attestations |
apiRequestIdentityAttestation(payload, attestationServiceUrl, platform, actionType) | Identity | Post encrypted identity session material to the attestation service |
apiCreateSellerCredentialBundle(payload, attestationServiceUrl, platform, timeoutMs?, runtime?) | Seller Autopilot | Create the encrypted seller credential bundle |
getAmazonPaySellerIdentity(bundle) | Seller Autopilot | Read the UPI ID the attestation service derived for an Amazon Pay bundle |
apiUploadSellerCredentialBundle(params, baseApiUrl?, timeoutMs?) | Seller Autopilot | Page-side: register or recover the payee, check bundle.payeeIdHash, store the bundle |
SellerCredentialAttestationRuntime | Both | Inject fetch, subtle and getRandomValues in extension runtimes |
PeerAuthenticateParams, PeerMetadataMessage, PeerMetadataRow, SellerCredentialBundle | Both | Types for the page contract |
Page-side calls (quotes, intents, fulfillment, credential uploads) are covered in the Client Reference.
Security requirements
- Keep plaintext in the extension. Encrypt session material in the extension, post only the encrypted blob and extracted rows, and discard the plaintext.
- Encrypt only to Peer's attestation service. The SDK helpers verify the service's Nitro attestation against bundled PCR pins. Don't accept an arbitrary attestation URL from the page.
- Return only the bundle for Seller Autopilot. The result holds
credentialBundleandoffchainId, nothing else. - Keep
requestBodyselector values private. Never copy them into returned rows. - Approve each third-party origin. Gate captures behind per-origin connection approval, as
requestConnection()does, and return results only to the requesting document and frame. You may pre-approve your own domains andlocalhost. - Keep capture state in memory. Scope capture sessions to the tab and clear them when the capture completes, fails or the tab closes. Persist only the user's connection approvals.
- Request only the hosts you need. Limit host permissions to the payment platforms you support.
- Keep maker logic in the page. The extension never holds API keys or calls Peer's API.
For help with templates or attestation service expectations, ask on Telegram.