@ppgppgppg/merchant-sdk
v1.0.7
Published
x402 Merchant SDK — compliance credential generation and buyer verification for paid APIs
Maintainers
Readme
x402 Merchant SDK
Centralized project documentation lives in ../docs. This README remains the package-level SDK reference for npm users.
Integrate your API with the x402 payment ecosystem — generate compliance credentials for payment challenges, and verify buyer identity before accepting payment.
When an AI agent calls your API through the x402 gateway, it follows the x402 HTTP payment protocol. This SDK handles the compliance layer on top of x402, so you can focus on building your API.
How it fits in
AI Agent (Cursor / Claude)
│
▼
x402 MCP Gateway ← buyer sends: x402-payer-id, x402-intent-vc, x402-payer-kyc-vc
│
▼ HTTP (x402 protocol)
Your API ◄── this SDK
│
│ Request 1 — no payment:
│ sdk.createPaymentChallengeExtras() → inject into 402 response header
│
│ Request 2 — payment present:
│ x402 facilitator verify()
│ sdk.verifyPayer() → local buyer KYC VC check
│ business logic
│ x402 facilitator settle() → on-chain USDC transfer
▼
Return 200 + payment-response headerPrerequisites
- Node.js >= 18
- A merchant account — apply at toani Dashboard to obtain the following credentials:
DASHBOARD_BASE_URL— the compliance API base URL (https://test-agentry-dashboard.zk.me/)FACILITATE_MERCHANT_API_KEY— your merchant API key (facilitate_...)FACILITATE_ID— your merchant ID (FACILITATE-...)
- An EVM wallet address to receive USDC payments
- Your API must implement the x402 protocol — return HTTP 402 with a
payment-requiredheader when no payment is present
Apply for a Merchant Account
- Visit toani Dashboard and register an account
- After logging in, go to Settings → API Keys page
- Click Generate API Key to create a new Merchant API Key (format:
facilitate_...) - View your Merchant ID (format:
FACILITATE-...) on the account information page - Configure your EVM wallet address as the payment recipient
Install
npm install @ppgppgppg/merchant-sdk
# or
pnpm add @ppgppgppg/merchant-sdkComplete Working Example (Express + x402)
Below is a production-ready Express endpoint that:
- Charges 0.10 USDC per request
- Generates a CartMandate and merchant KYC VC on the first call
- Tells the gateway which buyer KYC fields must be disclosed
- Verifies buyer KYC VC before accepting payment
- Settles on-chain via the x402 Facilitator
import express from "express";
import { MerchantSDK } from "@ppgppgppg/merchant-sdk";
import {
encodePaymentRequiredHeader,
encodePaymentResponseHeader,
decodePaymentSignatureHeader,
} from "@x402/core/http";
import { HTTPFacilitatorClient } from "@x402/core/server";
const app = express();
app.use(express.json());
// ── 1. Initialize the SDK ──────────────────────────────────────────────
const sdk = new MerchantSDK({
dashboardBaseUrl: process.env.DASHBOARD_BASE_URL!,
apiKey: process.env.FACILITATE_MERCHANT_API_KEY!,
merchantId: process.env.FACILITATE_ID!,
merchantAddress: process.env.MERCHANT_WALLET_ADDRESS!,
payTo: process.env.MERCHANT_WALLET_ADDRESS!,
});
// ── 2. Initialize the x402 Facilitator (on-chain settlement) ─────────
const facilitator = new HTTPFacilitatorClient(
process.env.FACILITATOR_URL ?? "https://x402.org/facilitator",
);
// ── 3. Define payment requirements ───────────────────────────────────
const PRICE_ATOMIC = "100000"; // 0.10 USDC (6 decimals)
const paymentRequirements = {
scheme: "exact",
network: "eip155:84532", // Base Sepolia testnet
asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", // USDC on Base Sepolia
amount: PRICE_ATOMIC,
payTo: process.env.MERCHANT_WALLET_ADDRESS!,
maxTimeoutSeconds: 300,
extra: { name: "USDC", version: "2" },
};
// ── 4. Your paid endpoint ─────────────────────────────────────────────
app.get("/api/data", async (req, res) => {
const signatureHeader = req.headers["payment-signature"] as
| string
| undefined;
// ── Request 1: No payment — return 402 challenge ──────────────────
if (!signatureHeader) {
const resourceUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
const paymentRequired = {
x402Version: 2,
error: "Payment required to access this resource",
resource: { url: resourceUrl, mimeType: "application/json" },
accepts: [{ ...paymentRequirements }],
};
// Generate CartMandate + merchant KYC VC + buyer disclosure requirement.
// Fail-close: if these fail, return 503.
let paymentChallengeExtras;
try {
paymentChallengeExtras = await sdk.createPaymentChallengeExtras({
price: "0.10",
currency: "USDC",
});
} catch (err) {
res
.status(503)
.json({ error: "PAYMENT_CHALLENGE_EXTRAS_FAILED", message: "Try again later." });
return;
}
// Inject CartMandate, merchant KYC VC, and payer disclosure requirement into the payment challenge.
paymentRequired.accepts[0].extra = {
...paymentRequired.accepts[0].extra,
...paymentChallengeExtras,
};
// Optional: bidirectional compliance check (recommended)
// If the gateway sent the buyer's intent VC, verify compatibility now
// so incompatible buyers are rejected before the payment attempt
const intentVcHeader = req.headers["x402-intent-vc"] as string | undefined;
if (intentVcHeader) {
let intentVcJson: Record<string, unknown>;
try {
intentVcJson = JSON.parse(
Buffer.from(intentVcHeader, "base64").toString("utf-8"),
);
} catch {
res.status(400).json({ error: "INVALID_INTENT_VC_HEADER" });
return;
}
const compatResult = await sdk.verifyBuyerIntent(
intentVcJson,
paymentChallengeExtras.cartMandate.vcJson,
);
if (!compatResult.passed) {
res.status(403).json({
error: "INTENT_INCOMPATIBLE",
code: compatResult.errorCode,
message: compatResult.reason,
});
return;
}
}
res.setHeader(
"payment-required",
encodePaymentRequiredHeader(paymentRequired as any),
);
res.status(402).json({ error: "Payment required" });
return;
}
// ── Request 2: Payment present — verify, check compliance, settle ─
let paymentPayload;
try {
paymentPayload = decodePaymentSignatureHeader(signatureHeader);
} catch {
res.status(400).json({ error: "INVALID_PAYMENT_SIGNATURE" });
return;
}
// Step A: Verify payment signature on-chain via x402 Facilitator
let verifyResult;
try {
verifyResult = await facilitator.verify(
paymentPayload,
paymentRequirements as any,
);
} catch (err) {
res.status(502).json({ error: "FACILITATOR_UNAVAILABLE" });
return;
}
if (!verifyResult.isValid) {
res
.status(402)
.json({
error: "PAYMENT_VERIFICATION_FAILED",
reason: (verifyResult as any).invalidReason,
});
return;
}
// Step B: Verify buyer identity — local KYC VC compliance
const payerFacilitateId = req.headers["x402-payer-id"] as string | undefined;
if (!payerFacilitateId) {
res.status(403).json({ error: "MISSING_PAYER_ID" });
return;
}
const payerKycVcHeader = req.headers["x402-payer-kyc-vc"] as
| string
| undefined;
if (!payerKycVcHeader) {
res.status(403).json({ error: "MISSING_PAYER_KYC_VC" });
return;
}
let payerKycVc;
try {
payerKycVc = JSON.parse(
Buffer.from(payerKycVcHeader, "base64").toString("utf-8"),
);
} catch {
res.status(400).json({ error: "INVALID_PAYER_KYC_VC_HEADER" });
return;
}
try {
const payerKycRequirement = await sdk.getPayerKycRequirement();
const verification = await sdk.verifyPayer(payerFacilitateId, {
payerKycVc,
fields: payerKycRequirement.fields,
});
if (!verification.passed) {
res
.status(403)
.json({
error: "COMPLIANCE_CHECK_FAILED",
reason: verification.reason,
});
return;
}
} catch {
res.status(503).json({ error: "COMPLIANCE_CHECK_ERROR" });
return;
}
// Step C: Execute your business logic here
const responseData = {
message: "Here is your data",
timestamp: new Date().toISOString(),
};
// Step D: Settle on-chain and return response
const settleResult = await facilitator.settle(
paymentPayload,
paymentRequirements as any,
);
res.setHeader(
"payment-response",
encodePaymentResponseHeader(settleResult as any),
);
res.json(responseData);
});
app.listen(process.env.PORT ?? 3000, () => {
console.log("Server running on port", process.env.PORT ?? 3000);
});Environment Variables
DASHBOARD_BASE_URL=https://test-agentry-dashboard.zk.me/ # toani Dashboard URL
FACILITATE_MERCHANT_API_KEY=agt_your_api_key # Get from Dashboard (Settings → API Keys)
FACILITATE_ID=AGT-XXXXX # Get from Dashboard (Account page)
MERCHANT_WALLET_ADDRESS=0xYourEVMWalletAddress # Your EVM wallet address (receives USDC)
FACILITATOR_URL=https://x402.org/facilitator # x402 on-chain settlement
PORT=3000Request & Response Flow
First Request (no payment-signature header)
Your API receives a regular HTTP request. Since there is no payment, return HTTP 402 with a payment-required header.
Required response header:
payment-required: <Base64-encoded PaymentRequired JSON>The PaymentRequired JSON structure:
{
"x402Version": 2,
"error": "Payment required",
"resource": {
"url": "https://your-api.com/endpoint",
"mimeType": "application/json"
},
"accepts": [
{
"scheme": "exact",
"network": "eip155:84532",
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"amount": "100000",
"payTo": "0xYourWalletAddress",
"maxTimeoutSeconds": 300,
"extra": {
"name": "USDC",
"version": "2",
"cartMandate": {
/* generated by sdk.createCartMandate() */
},
"merchantKycVc": {
/* generated by sdk.getMerchantKycVc() */
},
"payerKycRequirement": { "fields": ["kycStatus"] }
}
}
]
}Optional incoming header (bidirectional compliance):
x402-intent-vc: <Base64-encoded IntentVC JSON>If present, call sdk.verifyBuyerIntent() before returning the 402. Returns 403 if buyer's budget is insufficient.
Required outgoing extra fields for KYC VC flow:
The recommended path is to inject the object returned by sdk.createPaymentChallengeExtras({ price, currency }) directly into accepts[0].extra.
| Field | Description |
| --------------------- | ------------------------------------------------------------------------------------------------------- |
| cartMandate | AP2 CartMandate from sdk.createCartMandate() |
| merchantKycVc | Merchant KYC VC from sdk.getMerchantKycVc(["kycStatus"]); the gateway verifies this before paying |
| payerKycRequirement | Disclosure requirement from sdk.getPayerKycRequirement(); the gateway uses this to fetch buyer KYC VC |
Second Request (with payment-signature header)
Incoming headers:
payment-signature: <Base64-encoded PaymentPayload JSON>
x402-payer-id: FACILITATE-XXXXXXXX
x402-payer-kyc-vc: <Base64-encoded buyer KYC VC JSON>Required response header on success:
payment-response: <Base64-encoded SettleResponse JSON>API Reference
new MerchantSDK(config)
interface MerchantSDKConfig {
dashboardBaseUrl: string; // Compliance API base URL (from your account)
apiKey: string; // Your merchant API key (facilitate_...)
merchantId: string; // Your merchant ID (FACILITATE-...)
merchantAddress: string; // Your EVM wallet address
payTo: string; // EVM address to receive USDC (usually same as merchantAddress)
timeoutMs?: number; // Per-request timeout in ms. Default: 8000
maxRetries?: number; // Max retry attempts. Default: 5
}The SDK does not load .env files by itself. If your app reads credentials from .env, load them in your application entrypoint, for example with import "dotenv/config"; or dotenv.config(), before constructing MerchantSDK.
sdk.createPaymentChallengeExtras(params)
Call when: Handling the first request (no payment-signature).
Recommended high-level helper. It creates the AP2 CartMandate, fetches the merchant's own KYC VC with ["kycStatus"], and loads the merchant-configured buyer disclosure requirement from Dashboard.
const paymentChallengeExtras = await sdk.createPaymentChallengeExtras({
price: "0.10",
currency: "USDC",
});
paymentRequired.accepts[0].extra = {
...paymentRequired.accepts[0].extra,
...paymentChallengeExtras,
};Returns: Promise<PaymentChallengeExtras>
interface PaymentChallengeExtras {
cartMandate: CartMandate;
merchantKycVc: VcVerificationInput;
payerKycRequirement: { fields: string[] };
}Fail-close rule: If this throws, return HTTP 503. Do not return a partial 402 challenge.
sdk.createCartMandate(params)
Call when: Handling the first request (no payment-signature).
Generates a signed compliance credential (W3C Verifiable Credential) for this transaction. Must be injected into accepts[0].extra.cartMandate in your payment-required header.
const cartMandate = await sdk.createCartMandate({
price: "0.10",
currency: "USDC",
});| Parameter | Type | Description |
| ---------- | -------- | ------------------------------------ |
| price | string | Human-readable amount, e.g. "0.10" |
| currency | string | Currency symbol, e.g. "USDC" |
Returns: Promise<CartMandate>
interface CartMandate {
mandateId: string;
merchant_id: string;
total_amount: string;
currency: string;
vcJson: Record<string, unknown>; // W3C VC — inject into 402 extra
expiresAt: string; // ISO 8601
}Fail-close rule: If this throws, return HTTP 503. Never skip the CartMandate. The gateway will reject payments without it.
sdk.getPayerKycRequirement()
Call when: Handling the first request (no payment-signature).
Fetches the merchant-configured buyer KYC disclosure fields from Dashboard. If Dashboard returns no fields, the SDK defaults to ["kycStatus"].
Dashboard request failures, auth failures, or non-success business codes throw.
const payerKycRequirement = await sdk.getPayerKycRequirement();
paymentRequired.accepts[0].extra = {
...paymentRequired.accepts[0].extra,
payerKycRequirement,
};Returns: Promise<{ fields: string[] }>
sdk.getMerchantKycVc(fields?)
Call when: Handling the first request (no payment-signature).
Fetches the merchant account's own KYC VC from Dashboard. Inject it into accepts[0].extra.merchantKycVc; the gateway verifies this before signing payment.
const merchantKycVc = await sdk.getMerchantKycVc(["kycStatus"]);| Parameter | Type | Description |
| --------- | ---------- | ------------------------------------------------------ |
| fields | string[] | Disclosure fields to include. Default: ["kycStatus"] |
Returns: Promise<VcVerificationInput>
sdk.verifyPayerKycVc(payerKycVc, fields?)
Call when: You need to verify a buyer KYC VC directly.
This validates the VC signature, time window, disclosure hashes, and required fields. The kycStatus claim must equal "verified".
const result = await sdk.verifyPayerKycVc(payerKycVc, ["kycStatus"]);
if (!result.passed) {
res.status(403).json({ error: "KYC_VC_FAILED", reason: result.reason });
return;
}| Parameter | Type | Description |
| ------------ | --------------------- | ---------------------------------------------------- |
| payerKycVc | VcVerificationInput | Buyer KYC VC decoded from x402-payer-kyc-vc |
| fields | string[] | Required disclosure fields. Default: ["kycStatus"] |
Returns: Promise<VcVerificationResult>
sdk.verifyPayer(payerFacilitateId, options)
Call when: Handling the second request (after facilitator.verify() succeeds).
Runs local buyer KYC VC verification. Missing or invalid payerKycVc fails closed.
For stronger AP2 protection, pass both intentVcJson and cartMandateVcJson when they are available. verifyPayer() will then also verify that the buyer's IntentMandate is compatible with the CartMandate for this payment. Existing integrations that only pass payerKycVc and fields perform only the local KYC VC check.
const payerFacilitateId = req.headers["x402-payer-id"] as string;
const payerKycVc = JSON.parse(
Buffer.from(req.headers["x402-payer-kyc-vc"] as string, "base64").toString(
"utf-8",
),
);
const payerKycRequirement = await sdk.getPayerKycRequirement();
const result = await sdk.verifyPayer(payerFacilitateId, {
payerKycVc,
fields: payerKycRequirement.fields,
// Optional but recommended when your handler has both values:
intentVcJson,
cartMandateVcJson,
});
if (!result.passed) {
res
.status(403)
.json({ error: "COMPLIANCE_CHECK_FAILED", reason: result.reason });
return;
}| Parameter | Type | Description |
| ----------------------------- | ------------------------- | --------------------------------------------------------------------------- |
| payerFacilitateId | string | Buyer's facilitateId (value of the x402-payer-id request header) |
| options.payerKycVc | VcVerificationInput | Buyer KYC VC decoded from the x402-payer-kyc-vc request header |
| options.fields | string[] | Required KYC disclosure fields. Default: ["kycStatus"] |
| options.intentVcJson | Record<string, unknown> | Optional buyer IntentMandate VC for AP2/cart compatibility verification |
| options.cartMandateVcJson | Record<string, unknown> | Optional merchant CartMandate VC for AP2/cart compatibility verification |
Returns: Promise<PayerVerificationResult>
interface PayerVerificationResult {
passed: boolean;
kycResult?: VcVerificationResult;
intentCompatibilityResult?: IntentCompatibilityResult;
reason?: string; // human-readable failure reason when passed=false
}Fail-close rule: If
passedis false, reject (403). If the call throws, reject (503). Never serve a response when compliance fails.
sdk.verifyBuyerIntent(intentVcJson, cartMandateVcJson)
Call when: Handling the first request, if the x402-intent-vc header is present.
Performs a bidirectional compatibility check — verifies that the buyer's spending mandate is compatible with your CartMandate (currency match, per-transaction limit, total budget). Allows you to reject incompatible buyers early, before any payment attempt.
const compatResult = await sdk.verifyBuyerIntent(
intentVcJson,
cartMandate.vcJson,
);
if (!compatResult.passed) {
res.status(403).json({
error: "INTENT_INCOMPATIBLE",
code: compatResult.errorCode,
message: compatResult.reason,
});
return;
}| Parameter | Type | Description |
| ------------------- | ------------------------- | --------------------------------------------------------------------- |
| intentVcJson | Record<string, unknown> | Buyer's intent VC (decoded from x402-intent-vc header) |
| cartMandateVcJson | Record<string, unknown> | Your CartMandate VC (cartMandate.vcJson from createCartMandate()) |
Returns: Promise<IntentCompatibilityResult>
interface IntentCompatibilityResult {
passed: boolean;
errorCode?:
| "INTENT_VC_INVALID"
| "BUDGET_EXCEEDED"
| "PER_TX_LIMIT_EXCEEDED"
| "CURRENCY_MISMATCH"
| "COMPATIBILITY_FAILED"
| "VERIFY_BATCH_ERROR";
reason?: string;
compatibility?: {
compatible: boolean;
currencyMatch: boolean;
withinPerTxLimit: boolean;
withinTotalBudget: boolean;
cartAmount: string;
remainingBudget: string | null;
};
}This method remains useful for early rejection before returning a 402. For second-request verification, you can pass the same
intentVcJsonandcartMandateVcJsontoverifyPayer()so KYC and AP2/cart compatibility are reported in one result.verifyPayer()does not perform a separate AP2 intent existence check frompayerFacilitateId.
sdk.verifyVcCredential(input, options?)
Call when: You receive a VC JSON object directly and want to verify it locally inside your service (without dashboard verify APIs).
This method follows the full partner verification rules:
- Fetch public key from DID document (
kid->verificationMethod.id) - Verify Ed25519 JWT signature (
header.payload,signature) - Validate
issuedAt/expiresAtand JWTnbf/exp - Validate each disclosure hash exists in payload
_sd, then decode and check field-name consistency
const vcInput = {
credentialId: "urn:uuid:3fd5ed88-1e27-4ab6-af25-9c3e1133a098",
issuedAt: "2026-04-15T08:41:14Z",
expiresAt: "2027-04-15T08:41:14Z",
jwt: "header.payload.signature",
disclosures: {
kycStatus:
"WyI5OWZZamdVbDJEMkFaYmp6SnFDVXlRIiwia3ljU3RhdHVzIiwidmVyaWZpZWQiXQ",
},
};
const result = await sdk.verifyVcCredential(vcInput, {
didDocumentUrl: "https://test-toani-kyc-credential.zk.me/.well-known/did.json",
didCacheTtlMs: 300000,
clockSkewSec: 5,
});
if (!result.passed) {
console.error(result.errorCode, result.reason);
return;
}
console.log("verified claims:", result.verifiedClaims);| Parameter | Type | Description |
| ------------------------ | --------------------- | -------------------------------------------------------------------------- |
| input | VcVerificationInput | VC payload (credentialId, issuedAt, expiresAt, jwt, disclosures) |
| options.didDocumentUrl | string | DID document URL. Default: https://test-toani-kyc-credential.zk.me/.well-known/did.json |
| options.didCacheTtlMs | number | DID cache TTL in milliseconds. Default: 300000 |
| options.clockSkewSec | number | Allowed clock skew in seconds. Default: 0 |
Returns: Promise<VcVerificationResult>
interface VcVerificationResult {
passed: boolean;
errorCode?:
| "INVALID_INPUT"
| "INVALID_JWT_FORMAT"
| "INVALID_JWT_HEADER"
| "UNSUPPORTED_ALGORITHM"
| "KID_NOT_FOUND"
| "PUBLIC_KEY_NOT_FOUND"
| "INVALID_SIGNATURE"
| "TIME_WINDOW_INVALID"
| "JWT_TIME_INVALID"
| "INVALID_DISCLOSURE"
| "DISCLOSURE_HASH_MISMATCH"
| "DISCLOSURE_KEY_MISMATCH"
| "INVALID_PAYLOAD";
reason?: string;
kid?: string;
verifiedClaims: Record<string, unknown>;
disclosureCount: number;
matchedDisclosureCount: number;
}Production notes:
- Use the latest key from DID document, do not hardcode test keys.
- Keep fail logs (
errorCode+reason) for troubleshooting and auditing. - If verification fails, reject request (fail-close), do not continue business flow.
Error Reference
| HTTP Status | Error Code | When it occurs |
| ----------- | ----------------------------- | --------------------------------------------------------------- |
| 400 | INVALID_PAYMENT_SIGNATURE | payment-signature header is malformed |
| 400 | INVALID_INTENT_VC_HEADER | x402-intent-vc header is not valid Base64 JSON |
| 400 | INVALID_PAYER_KYC_VC_HEADER | x402-payer-kyc-vc header is not valid Base64 JSON |
| 402 | PAYMENT_VERIFICATION_FAILED | x402 Facilitator rejected the payment signature |
| 403 | MISSING_PAYER_ID | x402-payer-id header is missing on second request |
| 403 | MISSING_PAYER_KYC_VC | x402-payer-kyc-vc header is missing on second request |
| 403 | COMPLIANCE_CHECK_FAILED | Buyer failed KYC VC or AP2 check |
| 403 | INTENT_INCOMPATIBLE | Buyer's budget is insufficient for this transaction |
| 502 | FACILITATOR_UNAVAILABLE | Could not reach the x402 Facilitator |
| 503 | CART_MANDATE_FAILED | Could not generate CartMandate (compliance service unavailable) |
| 503 | COMPLIANCE_CHECK_ERROR | Compliance service threw an unexpected error |
Register Your API
Once your API is live, register it on the Dashboard so AI agents can discover and call it:
- Log in to toani Dashboard
- Go to Tools → Register Tool
- Fill in the following fields:
| Field | Example | Description |
| ------------ | ---------------------------------------------------------------------- | ----------------------------------------------------------- |
| Tool Name | weather_get_current | Unique identifier used as the MCP tool name by the AI agent |
| Description | "Get current weather for a city" | The AI reads this to decide when to call your tool |
| Input Schema | { "type": "object", "properties": { "city": { "type": "string" } } } | JSON Schema of the parameters your endpoint accepts |
| Endpoint URL | https://your-api.com/v1/weather | Full URL of your paid endpoint |
| HTTP Method | GET or POST | HTTP method |
| Price | 0.10 USDC | Amount charged per call |
- Save — your tool is now discoverable by any agent using the toani Gateway
Tip: The
descriptionfield directly influences when the AI decides to call your tool. Make it specific and action-oriented: "Get real-time stock price for a given ticker symbol" is better than "Stock data".
Checklist Before Going Live
- [ ]
createPaymentChallengeExtras()called on first request, full result injected intoaccepts[0].extra - [ ]
payment-requiredheader set with Base64-encoded PaymentRequired JSON - [ ]
facilitator.verify()called before executing any business logic - [ ]
x402-payer-idandx402-payer-kyc-vcheaders read beforeverifyPayer() - [ ]
verifyPayer()result checked — return 403 ifpassed=false - [ ] Business logic runs only after all checks pass
- [ ]
facilitator.settle()called before sending the 200 response - [ ]
payment-responseheader set on 200 response - [ ] All errors follow fail-close: 503 if compliance service is down, never serve without verification
- [ ] Tool registered on the merchant dashboard
License
MIT
