Skip to main content

Example: Server Payment Flow

Source: examples/simple/server.ts

Server mode adds Context V2 — sensitive payment data (verified timestamp, spend tracking, KYC, oracle data) that must be signed by trusted issuers before the rule engine will accept them.


When to Use Server Mode

Use CaseMode
Rules only check tx.* fieldsClient ✅
Server-verified timestampServer
Spend tracking from your databaseServer
KYC status / oracle dataServer
Geoblocking (oracle.country)Server
Rule with "requires": ["oracle"]Server

Run

bun examples/simple/server.ts

Key Difference: buildContextV2

In client mode, the payer builds context themselves. In server mode, your backend attaches signed attestations to each sensitive field. The rule engine rejects any attestation that isn't signed by a trusted issuer.

import { buildContextV2 } from "payid/context";
import { ethers } from "ethers";

// These signers live on YOUR BACKEND — never expose their private keys to clients
const envSigner = new ethers.Wallet(process.env.ISSUER_PRIVATE_KEY!, provider);
const stateSigner = envSigner; // can reuse same signer for all fields
const oracleSigner = envSigner;

const contextV2 = await buildContextV2({
baseContext: {
tx: {
sender: payerWallet.address,
receiver: RECEIVER,
asset: USDC_ADDRESS,
amount: AMOUNT.toString(),
chainId: Number(process.env.CHAIN_ID),
},
env: {
timestamp: Math.floor(Date.now() / 1000),
},
},
// Each field gets an EIP-712 attestation signed by the respective issuer
env: {
issuer: envSigner,
},
state: {
issuer: stateSigner,
spentToday: await getSpentToday(payerWallet.address), // from your DB
period: new Date().toISOString().slice(0, 10), // "2025-01-15"
},
oracle: {
issuer: oracleSigner,
data: {
country: await getCountry(payerWallet.address), // e.g. "ID"
kycLevel: await getKYCLevel(payerWallet.address), // e.g. "2"
},
},
});

Initialize SDK in Server Mode

import { createPayIDServer } from "payid/server";

const payid = createPayIDServer({
signer: serverWallet, // server wallet that signs Decision Proofs
// List every issuer address whose attestations you trust
trustedIssuers: new Set([
envSigner.address,
stateSigner.address,
oracleSigner.address,
]),
});
// No ready() call needed — PayIDServer is always ready
warning

If an issuer address is not in trustedIssuers, the SDK will reject any attestation signed by that address — and evaluation will fail with FIELD_NOT_FOUND or CONTEXT_OR_ENGINE_ERROR for the affected fields.

Signer in constructor

Unlike the client, PayIDServer receives its signer at construction time. This wallet signs all Decision Proofs — keep it on the server, never expose its private key to clients.


Evaluate + Generate Proof

Same as client mode, but pass contextV2 instead of the plain context:

const blockTimestamp = Math.floor(Date.now() / 1000);

const { result, proof } = await payid.evaluateAndProve({
context: contextV2, // ← V2 with attestations
authorityRule,
payId: "pay.id/merchant",
payer: payerWallet.address,
receiver: RECEIVER,
asset: USDC_ADDRESS,
amount: AMOUNT,
// No `signer` here — server signer was injected in createPayIDServer()
verifyingContract: process.env.PAYID_VERIFIER!,
ruleAuthority: process.env.COMBINED_RULE_STORAGE!,
ruleSetHashOverride: ruleSetHash,
chainId: Number(process.env.CHAIN_ID),
blockTimestamp,
ttlSeconds: 300,
});

if (result.decision === "REJECT") {
throw new Error(`Payment rejected: ${result.reason ?? result.code}`);
}

Example Rule Requiring Server Mode

Rules that use oracle.*, state.*, or risk.* must declare them in "requires":

{
"version": "1",
"logic": "AND",
"requires": ["oracle", "state"],
"rules": [
{
"id": "kyc_required",
"if": { "field": "oracle.kycLevel", "op": ">=", "value": "2" },
"message": "KYC level 2 or higher required"
},
{
"id": "id_only",
"if": { "field": "oracle.country", "op": "==", "value": "ID" },
"message": "Only Indonesian users accepted"
},
{
"id": "daily_limit",
"if": { "field": "state.spentToday", "op": "<=", "value": "500000000" },
"message": "Daily spending limit of 500 USDC exceeded"
}
]
}

The "requires" array tells the rule engine which context modules to verify before evaluation. If oracle is declared but the context has no valid oracle attestation, evaluation fails with FIELD_NOT_FOUND.


Typical Architecture

[Client browser]                  [Your backend server]
│ │
│── POST /api/prepare-payment ──────►│
│ │── buildContextV2()
│ │ (signs env, state, oracle)
│◄── { contextV2, ruleSetHash } ─────│

│── evaluateAndProve(contextV2, ...) ← runs in browser
│── payERC20(proof.payload, sig, [])

The backend attaches attestations. The payer still signs the final proof with their own wallet in the browser — your server never touches their private key.


Notes

Never expose issuer private keys to clients. The buildContextV2 call and the issuer signers must stay on your server.

Context V2 attestations expire. Each attestation includes a short TTL. The buildContextV2 function sets a default TTL of a few minutes. Don't cache contextV2 objects across requests.

Mixing V1 and V2 context. You can pass a plain RuleContext to server-mode evaluateAndProve — the SDK will just skip attestation verification for any missing modules.