Skip to main content

Server-Side Integration (Wallet Signatures)

Onramps and other backends can create intents for their customers without a browser session. Each request to Peer's API is signed by the customer's own wallet. There is no API key and no Privy login: the wallet signature is the only credential.

Every public intent needs a verified payment account: a customer proves they own, for example, their Cash App account, and Peer links that account to their wallet. Peer then signs intents only for that wallet, and limits how many open intents each payment account can hold. This keeps any single account from locking up sellers' liquidity.

Flow​

  1. Pick one wallet per customer. Use a stable EOA that you control for each customer, and reuse it for every order. Don't create a new wallet per order: a payment account can link at most 5 new wallets in 24 hours.
  2. Verify the customer's payment account. Request an identity attestation from the attestation service with the customer's session, with callerAddress set to the customer's wallet. See Identity attestation for each platform's action type and session material. Then send it to POST /v3/identity. An enrollment stays valid for 30 days; enroll again after that.
  3. Get a gating signature. Call POST /v3/sign with callerAddress set to the customer's wallet.
  4. Signal the intent on OrchestratorV4 from the same wallet. With @zkp2p/sdk, pass the signature you received to signalIntent() (see below).
  5. Get the payee. Once the intent is open onchain, call GET /v2/makers/intent/:intentHash. Payee details are only revealed to the intent's owner after the intent exists.

All of these calls go to https://api.zkp2p.xyz and carry the wallet-signature headers below.

RouteUse
POST /v3/identityEnroll the customer's payment account for the signing wallet
GET /v3/identity?platform=&callerAddress=Check that enrollment
GET /v3/identity/accounts?callerAddress=List the wallet's enrolled accounts
POST /v3/signGet the OrchestratorV4 gating signature for an intent
GET /v2/makers/intent/:intentHashReveal the payee after the intent is open onchain

GET /v3/identity/platforms (no authentication) lists the platforms that accept enrollment.

Authentication​

Send these three headers, and no Authorization header (a Bearer token always selects the browser login):

HeaderValue
X-Peer-Wallet-AddressThe signing wallet (EOA), 0x plus 40 hex characters
X-Peer-Wallet-ExpiresUnix seconds; later than now and at most 300 seconds ahead
X-Peer-Wallet-SignatureAn EIP-191 personal_sign signature of the message below

Smart-contract wallets (ERC-1271, ERC-6492) are not supported.

The signed message​

Sign this exact UTF-8 text. Lines end in LF (\n) and there is no trailing newline:

Peer API request v1
Audience: peer-prod
Wallet: <lowercase 0x wallet address>
Method: <uppercase HTTP method>
Target: <path plus query string, exactly as sent>
Body SHA-256: <lowercase hex SHA-256 of the raw request body>
Expires: <the X-Peer-Wallet-Expires value>
  • Audience is peer-prod for https://api.zkp2p.xyz. A signature for another audience is rejected.
  • Target is the request target exactly as your HTTP client sends it, for example /v3/identity?platform=cashapp&callerAddress=0xabc.... Build the URL once and sign that string; a differently encoded or reordered query string fails.
  • Body SHA-256 covers the exact bytes you send. Serialize the JSON once and send that string. A request without a body uses the SHA-256 of the empty string, e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
  • Bodies must be Content-Type: application/json.

Each signature works once. Sign every request, including retries, anew.

Example (viem)​

import { createHash } from "node:crypto";
import { privateKeyToAccount } from "viem/accounts";

const PEER_API = "https://api.zkp2p.xyz";
const AUDIENCE = "peer-prod";

// One stable wallet per customer; keep its key in your signer, never in code.
const customerWallet = privateKeyToAccount(
process.env.CUSTOMER_WALLET_PRIVATE_KEY as `0x${string}`,
);

async function peerFetch(
method: "GET" | "POST",
target: string,
payload?: unknown,
) {
const body = payload === undefined ? "" : JSON.stringify(payload);
const expires = String(Math.floor(Date.now() / 1000) + 240);
const message = [
"Peer API request v1",
`Audience: ${AUDIENCE}`,
`Wallet: ${customerWallet.address.toLowerCase()}`,
`Method: ${method}`,
`Target: ${target}`,
`Body SHA-256: ${createHash("sha256").update(body).digest("hex")}`,
`Expires: ${expires}`,
].join("\n");
const signature = await customerWallet.signMessage({ message });

const response = await fetch(`${PEER_API}${target}`, {
method,
headers: {
"X-Peer-Wallet-Address": customerWallet.address,
"X-Peer-Wallet-Expires": expires,
"X-Peer-Wallet-Signature": signature,
...(payload === undefined ? {} : { "Content-Type": "application/json" }),
},
body: payload === undefined ? undefined : body,
});
if (!response.ok)
throw new Error(
`${method} ${target}: ${response.status} ${await response.text()}`,
);
return response.json();
}

// 1. Enroll the customer's payment account. The attestation's callerAddress must be customerWallet.
await peerFetch("POST", "/v3/identity", { identityAttestation });

// 2. Get the gating signature. callerAddress must be customerWallet.
const signed = await peerFetch("POST", "/v3/sign", {
...signRequest, // processorName, payeeDetails, depositId, amount, toAddress, paymentMethod, fiatCurrency, ...
callerAddress: customerWallet.address,
referralFees: [],
});

// 3. After the intent is open onchain, reveal the payee.
const payee = await peerFetch("GET", `/v2/makers/intent/${intentHash}`);

Signal with the SDK​

signalIntent() fetches a gating signature itself only when the client has a Peer login token. With wallet signatures, signal with the values the signature covers, taken from the /v3/sign response, not from your request:

const { intentData } = signed.responseObject;

await client.signalIntent({
processorName: signRequest.processorName,
payeeDetails: signRequest.payeeDetails,
depositId: intentData.depositId,
amount: intentData.amount,
toAddress: intentData.recipientAddress,
fiatCurrencyCode: intentData.fiatCurrency,
conversionRate: intentData.conversionRate,
referralFees: intentData.referralFees,
escrowAddress: intentData.escrowAddress,
orchestratorAddress: intentData.orchestratorAddress,
gatingServiceSignature: intentData.gatingServiceSignature,
signatureExpiration: BigInt(intentData.signatureExpiration),
payerCommitment: intentData.payerCommitment,
});
warning

Peer adds its service fee to referralFees when it signs, so the response's referralFees differ from the empty list you sent. Signal with the response's values; otherwise signalIntent reverts with InvalidSignature (0x8baa579f).

The client must use customerWallet as its wallet, because the signature only admits an intent from that wallet.

Limits​

These values may change; build your retries around the 429 and Retry-After responses rather than the numbers.

  • Open intents: 2 per payment account at a time, across all of its wallets.
  • New wallets: 5 newly linked wallets per payment account per 24 hours. Re-enrolling a linked wallet always works.
  • Signatures: per payment account, 2 a minute, 6 an hour and 20 a day.
  • Per server IP: 10 wallet-signed requests a minute, and 50 gating signatures a minute, 100 an hour and 500 a day. A server signing for many customers shares these across them. Contact Peer if your volume needs more.

Errors​

Status and errorCodeMeaning
401 wallet_auth_invalidMalformed headers, an expired or too-distant expiry, a non-JSON body, a signature that doesn't match the message or wallet, or a reused signature
403 intent_caller_not_linkedcallerAddress isn't the signing wallet
403 intent_identity_requiredThe wallet has no current payment-account enrollment for this platform. Enroll it with POST /v3/identity
403 identity_platform_disabled, 403 intent_identity_unsupportedThe platform isn't open to enrollment or signing
409 intent_open_limit_reached_identityThe payment account already has 2 open intents. Wait for one to complete or cancel it
429 identity_wallet_link_limitThe payment account linked 5 new wallets in the last 24 hours
429A rate limit was reached; wait for Retry-After
503Peer can't verify the request right now; retry after Retry-After