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
- 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.
- Verify the customer's payment account. Request an identity attestation from the attestation service with the customer's session, with
callerAddressset to the customer's wallet. See Identity attestation for each platform's action type and session material. Then send it toPOST /v3/identity. An enrollment stays valid for 30 days; enroll again after that. - Get a gating signature. Call
POST /v3/signwithcallerAddressset to the customer's wallet. - Signal the intent on OrchestratorV4 from the same wallet. With
@zkp2p/sdk, pass the signature you received tosignalIntent()(see below). - 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.
| Route | Use |
|---|---|
POST /v3/identity | Enroll 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/sign | Get the OrchestratorV4 gating signature for an intent |
GET /v2/makers/intent/:intentHash | Reveal 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):
| Header | Value |
|---|---|
X-Peer-Wallet-Address | The signing wallet (EOA), 0x plus 40 hex characters |
X-Peer-Wallet-Expires | Unix seconds; later than now and at most 300 seconds ahead |
X-Peer-Wallet-Signature | An 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-prodforhttps://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,
});
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 errorCode | Meaning |
|---|---|
401 wallet_auth_invalid | Malformed 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_linked | callerAddress isn't the signing wallet |
403 intent_identity_required | The wallet has no current payment-account enrollment for this platform. Enroll it with POST /v3/identity |
403 identity_platform_disabled, 403 intent_identity_unsupported | The platform isn't open to enrollment or signing |
409 intent_open_limit_reached_identity | The payment account already has 2 open intents. Wait for one to complete or cancel it |
429 identity_wallet_link_limit | The payment account linked 5 new wallets in the last 24 hours |
429 | A rate limit was reached; wait for Retry-After |
503 | Peer can't verify the request right now; retry after Retry-After |