ZentPay Developer Integration Guide
ZentPay lets games, mini apps, AI tools, and web apps charge from 1 zent.
1 zent = 0.01 USDC
The user signs one USDC EIP-2612 Permit for BudgetSpenderV2. ZentPay relays that permit and later charge calls, so the user does not need ETH and does not confirm every paid action. Developer funds go directly to the app's Base USDC pay_to address. ZentPay does not custody developer revenue.
1Current Status
- Base Sepolia is the active network for v1.2.
- Active BudgetSpenderV2 is
0xb524F0b6b5e35d8b4c24455fcab0390c20Be0324. - Product routes require product-bound, one-time payment
authorizationCode; rawauthorizationIdand summary codes are for history, handshake, management, receipts, and debug only. - Account Binding handoffs are available. If the same game/app account already has an active ZentPay connection and remaining budget, the backend handoff response returns
status: "ready"with a short-lived summaryauthorizationCode; exchange it for a product-bound payment code before the product route call. - The wallet budget is global to the user's ZentPay account. Each app/game still needs its own account connection before it can reuse that budget.
payment.deliveredis the fulfillment webhook event.- External static site, Hosted Entry, and Worker Quick Launch are available now.
2Production URLs
- Portal: https://portal.zentpay.app
- Dev Console: https://dev.zentpay.app
- Docs: https://docs.zentpay.app
- Payment API: https://api.zentpay.app
- Official Entry: https://portal.zentpay.app/<app-slug>
- App Runtime: https://<app-slug>.apps.zentpay.app
- OpenAPI: https://docs.zentpay.app/openapi.json
- Webhook schema: https://docs.zentpay.app/webhook-event.schema.json
- Runtime manifest schema: https://docs.zentpay.app/runtime-manifest.schema.json
- SDK / CLI:
@zentpay/x402-pay@0.2.12
The server SDK helper and CLI examples below require @zentpay/x402-pay 0.2.12 or newer. The published 0.2.11 package does not include createTrustedServerCharge:
npm install @zentpay/x402-pay@^0.2.12
3Payment Path Decision Table
Choose one payment path before wiring UI or backend state. All three paths keep the no-gas BudgetSpender experience; the difference is where the purchase intent is created and which origin is allowed to spend the authorization.
| Path | Best Fit | Who Creates The Intent | Browser Step | Server Step |
|---|---|---|---|---|
| Hosted Entry / product route | First launch, simple product buttons, or a runtime that can call the app-origin payment route. | Browser creates a product-bound intent with POST /api/authorizations/payment-code. | Call https://api.zentpay.app/api/pay/<app>/<product> with the one-time payment code. | Fulfill only from signed payment.delivered webhook or trusted delivery lookup. |
| Backend-created order + browser handoff | Dynamic pricing, quotes, cart/order review, or backend inventory reservation before payment. | Backend creates POST /api/orders; browser completes account handoff and exchanges the summary through POST /api/authorizations/payment-code. | Browser passes the order-bound payment code to the product route. | Backend reconciles order, delivery, and idempotent fulfillment. |
Trusted-server /api/charges | Logged-in accounts with an existing account connection and repeated low-value in-app purchases. | Backend calls POST /api/charges through createTrustedServerCharge() with a server-only zpk_... key. | Browser only asks the backend to buy the known product for the logged-in user. | Backend verifies paymentProof, grants once, then reconciles the webhook. |
For a game like gemix where the user has already bound a game account and wants small in-game purchases without a second browser exchange, prefer the trusted-server charge path. Use Hosted Entry / product route for the first integration or for public buy buttons, and use backend-created orders when the purchase amount or inventory reservation must be decided by your backend first.
4Agent Integration Guardrails
When an AI coding agent integrates ZentPay, keep the implementation inside these guardrails:
- Use only the three payment paths in the table above. Do not invent a direct database integration, a frontend-only charge path, or a no-origin backend exchange for
/api/authorizations/payment-code. - External apps must not depend on ZentPay's private implementation or storage. Payment evidence is a signed
payment.deliveredwebhook, a verifiedpaymentProof, or a trusted delivery lookup performed by the developer backend with a server-onlyzpk_...key. ZENTPAY_RUNTIME_ORIGINis the developer-owned deployed origin. In production it must not behttp://localhost,http://127.0.0.1:3000, orhttps://<app-slug>.apps.zentpay.app.ZENTPAY_RETURN_URLfor production account binding should behttps://<app-slug>.apps.zentpay.app/zentpay-auth-return?app=<app-slug>. The return page must refresh account state and recover the saved pending intent; never grant, bind, or spend by trusting URL parameters alone.- Dev Console config such as
zentpay.config.jsonis public-ish control-plane config. One-time secrets (ZENTPAY_API_KEY,ZENTPAY_WEBHOOK_SECRET) must be deployed through server env/secret tooling.zentpay keys pullcan only write masked guidance, not recover an old secret. - All examples in this guide use
gemix. Replace the app slug, URL, product SKU, idempotency key prefix, and source labels with the developer app's own values.
5Recommended Quickstart
Use this path for a new external developer integration. It is the shortest repeatable loop from a local project to a Dev Console-backed payment test.
# 1. Install the SDK and CLI package.
npm install -D @zentpay/x402-pay@0.2.12
# 2. Scaffold a Next.js App Router backend with proof-first fulfillment,
# webhook reconcile, inventory sync routes, manifest, env hints, and tests.
npx zentpay init next --app <app-slug> --env .env.local
# 3. Approve a short-lived Console grant for local CLI or agent setup.
npx zentpay auth browser \
--app <app-slug> \
--scopes apps:read,apps:write,products:write,webhooks:write,runtime:write,keys:create \
--ttl 2h
npx zentpay auth status --json
# 4. Plan first, then apply the desired Console config.
npx zentpay config plan --file zentpay.config.json --mode mirror --json
npx zentpay config apply --file zentpay.config.json --mode mirror --yes --json
# 5. Create a server-only backend key for account handoff, orders, deliveries,
# and receipts. Store the full zpk_... secret immediately.
npx zentpay keys create \
--app <app-slug> \
--label "Backend key" \
--scopes orders:create,orders:read,charges:create,deliveries:read,receipts:read,connections:create,connections:read \
--env .env.local
# 6. Check the integration before the first payment.
npx zentpay doctor --app <app-slug> --json
CLI grant is not a backend API key. zentpay auth browser authorizes control-plane setup such as apps, products, webhooks, runtime, config plan/apply, and keys:create. Runtime handoff, order creation, delivery lookup, and receipt lookup still require a server-side ZENTPAY_API_KEY beginning with zpk_....
After the commands above, open Dev Console -> Overview -> Integration checks and confirm the API key scopes, webhook reachability, runtime origin, product price units, and payment loop. Then run one test payment and grant value only from a signed payment.delivered webhook or trusted delivery lookup.
Keep zentpay.config.json public-ish: it may contain app, products, webhooks, runtime, metadata, and environment; it must not contain API key secrets, webhook signing secrets, admin credentials, relayer keys, private keys, tokens, or passwords. Use --mode mirror when the config file should be the complete source of truth; use --mode merge when Console may keep manual resources that are not declared in the file.
6Hosting Modes
ZentPay supports payment, official app entry, and Worker Quick Launch runtime flows today.
| Mode | Status | What ZentPay Hosts | What Developer Hosts |
|---|---|---|---|
| External static site | Available now | Payment API, Portal authorization, Dev Console, webhooks | Game/app frontend and fulfillment backend |
| Hosted Entry | Available now | https://portal.zentpay.app/<app-slug> official profile, authorization bridge, Portal return flow | Game/app frontend if using an external URL, plus fulfillment backend |
| Worker Quick Launch | Available now, runtime domain approval required | https://<app-slug>.apps.zentpay.app runtime router, manifest verification, and inactive/setup page | Developer Cloudflare Worker with static frontend and fulfillment backend |
ZentPay keeps the public trust surface simple: portal.zentpay.app/<app-slug> is the official app entry, authorization entry, return URL, and fallback page. Use <app-slug>.apps.zentpay.app only for the actual developer app runtime when using Worker Quick Launch.
Reserved app slugs cannot be registered because they belong to ZentPay system routes:
dev, docs, api, app, apps, authorize, health, statusz, supported, verify,
settle, assets, admin, login, settings, account
Third-party app code should run on your own domain or on <app-slug>.apps.zentpay.app, not on Portal origin. Do not set parent-domain .zentpay.app cookies for app runtime state. ZentPay's runtime proxy strips request cookies and upstream Set-Cookie headers.
Hosted Entry is not full game/app file hosting. It gives a developer an official public URL, app profile, authorization bridge, and product payment routes. If your app already has static files, you can deploy them to Vercel, Netlify, Cloudflare Pages, GitHub Pages, your own server, or any other static host and point users through ZentPay authorization and payment.
7Local Development URL Rules
Account handoff return URLs and webhook URLs have different rules.
Account handoff returnUrl accepts:
https://portal.zentpay.app/<app-slug>.https://<app-slug>.apps.zentpay.app/<path>after runtime setup.http://localhost:<port>/<app-slug>/<path>orhttp://127.0.0.1:<port>/<app-slug>/<path>for local development. The first path segment must match the app slug.
Webhook URLs must be public HTTPS endpoints. ZentPay rejects localhost, private IP ranges, .local, .internal, and cloud metadata hosts for webhook delivery. For local webhook development, expose your backend with ngrok, Cloudflare Tunnel, a Vercel preview URL, or another public HTTPS tunnel, then set that URL as the webhook endpoint.
Runtime origin must be a developer-owned HTTPS origin. Do not set runtime.origin to https://<app-slug>.apps.zentpay.app or any other *.zentpay.app host; that host is ZentPay's reviewed proxy in front of your origin.
Server environment values that must point to public URLs:
ZENTPAY_RUNTIME_ORIGIN=https://your-worker.workers.dev
ZENTPAY_RETURN_URL=https://<app-slug>.apps.zentpay.app/zentpay-auth-return?app=<app-slug>
ZENTPAY_WEBHOOK_URL=https://your-public-backend.example.com/api/zentpay/webhook
Use this URL matrix before production deploy:
| Environment | ZENTPAY_RUNTIME_ORIGIN | ZENTPAY_RETURN_URL | Allowed Purpose |
|---|---|---|---|
| Local dev | http://127.0.0.1:<port> or http://localhost:<port> | http://127.0.0.1:<port>/<app-slug>/... only for local account handoff testing | Local browser development only; never deploy these values. |
| Preview/test | Public HTTPS preview such as a Worker, Vercel, or tunnel URL | Matching public HTTPS callback URL or the hosted callback for the test app | Shared QA and Dev Console verification. |
| Production | Developer-owned public HTTPS origin, for example https://your-worker.workers.dev or a custom domain | https://<app-slug>.apps.zentpay.app/zentpay-auth-return?app=<app-slug> | Real account binding, pending purchase recovery, and runtime return flow. |
Run zentpay doctor --app <app-slug> --env-file .env.production --json before deploy. The doctor fails production-like config that still contains http://127.0.0.1:3000, localhost, private IPs, or a ZentPay proxy host in ZENTPAY_RUNTIME_ORIGIN.
Keep ZENTPAY_API_KEY and ZENTPAY_WEBHOOK_SECRET server-side only.
8Worker Quick Launch
With Worker Quick Launch, the developer deploys a Cloudflare Worker. ZentPay verifies the Worker manifest, then can route the approved runtime hostname:
https://<app-slug>.apps.zentpay.app
The Worker can serve the static game/app frontend and the fulfillment backend in one deploy.
The starter template implements these endpoints:
GET /
GET /.well-known/zentpay.json
POST /api/zentpay/purchase
POST /api/zentpay/webhook
POST /api/zentpay/payment-submitted
POST /api/zentpay/reconcile
GET /api/inventory
POST /api/inventory/sync
/api/zentpay/purchase, /api/zentpay/reconcile, and /api/inventory/sync are app-owned adapter stubs in the template. They do not create or promise a new ZentPay public API. Connect them to the existing server SDK helpers and your own fulfillment code before production.
The starter homepage includes the expected self-service recovery UX. It loads GET /api/inventory as the only visible inventory source, saves a local pending purchase hint before opening ZentPay, and treats return URLs, callbacks, and postMessage events as sync hints only. A callback first calls POST /api/zentpay/payment-submitted so the backend can recover by trusted delivery lookup or verified paymentProof, then calls POST /api/inventory/sync to refresh server-confirmed inventory. If no grant is visible yet, the user can click Sync purchase, which calls POST /api/zentpay/reconcile and then refreshes inventory again.
Repeated Sync purchase clicks must be idempotent. In production, the reconcile path should verify a signed paymentProof or perform trusted backend delivery lookup, reserve the same durable ZentPayFulfillmentStore ledger row, and return the existing grant when deliveryId or idempotencyKey was already handled. A browser success query parameter, callback payload, or postMessage must never create inventory by itself. ZentPay does not host the developer's business inventory database for this template; the app backend owns that durable store.
Worker secrets:
ZENTPAY_APP_ID=
ZENTPAY_APP_SLUG=
ZENTPAY_PAY_TO=
ZENTPAY_RUNTIME_ORIGIN=
ZENTPAY_RUNTIME_APP_PATH=/
ZENTPAY_RETURN_URL=https://<app-slug>.apps.zentpay.app/zentpay-auth-return?app=<app-slug>
ZENTPAY_WEBHOOK_SECRET=
ZENTPAY_API_KEY= optional, backend-only trusted charge, order, delivery, or receipt flow
Runtime verification checks:
GET <runtimeOrigin>/.well-known/zentpay.json
manifest.appId or manifest.app_id == ZentPay app id
manifest.slug or manifest.appSlug or manifest.app_slug == app slug
manifest.payTo or manifest.pay_to == app pay_to
manifest.webhookPath or manifest.webhook_path == /api/zentpay/webhook
manifest.paymentSubmittedPath or manifest.payment_submitted_path == /api/zentpay/payment-submitted
manifest.appPath or manifest.app_path == the runtime app path, default /
manifest.entryPath or manifest.entry_path == the browser return entry path, default /
manifest.runtimeOrigin, when present, == the origin saved in Dev Console
appPath and entryPath must be concrete paths such as / or /play; do not publish them as null. Full redirect and new-window authorization returns use this path to leave the callback page and return to the app. If it is missing or null, the user can land on the runtime root instead of the intended game/app screen.
Runtime status values:
not_configured -> pending_verification -> active
active -> disabled
Dev Console runtime flow:
- Deploy the Worker to
https://your-worker.workers.dev. - Click
Save + Verifyin the app's Runtime section. Dev Console saves the origin, checks/.well-known/zentpay.json, probes the expected endpoints, and activates the runtime when the checks pass. - Use
Recheck Runtimelater only if you need to re-run the technical probe. - Click
Request Domainafter activation. ZentPay reviews and activates the exact runtime hostname before it becomes public.
The runtime URL is the app surface. portal.zentpay.app/<app-slug> remains the official profile and authorization entry that can link to the runtime URL.
9Demo Vs Real-Money Apps
A pure static game can go online only as a demo site without a backend. That is fine for playtesting, showcasing UI, and testing navigation.
For real ZentPay payments, every app needs a trusted server-side fulfillment layer. The frontend may call a Direct Product Route, but the frontend must not grant coins, items, passes, levels, exports, or downloads by itself. Frontend success is only UI feedback.
Portal login is the user's payment identity, not the developer's product backend. If a product spends USDC and then delivers value, it needs either the developer backend or a ZentPay-managed backend when that product is explicitly offered. Hosted Entry and Portal Account are authorization and management surfaces; they do not replace inventory, entitlement, anti-abuse, or fulfillment storage.
Use any backend stack you like:
- Vercel Functions.
- Next.js API routes.
- Cloudflare Workers.
- Serverless functions.
- Your own Node, Go, Python, or game backend.
Minimum real-money backend responsibilities:
- Verify
paymentProofor perform trusted delivery lookup before immediate backend fulfillment when using trusted-server charges or recovery sync. - Receive signed
payment.deliveredwebhooks for reconciliation. - Verify
x-zentpay-signaturefrom the raw request body. - Fulfill once through one shared idempotent ledger keyed by
deliveryIdandidempotencyKey. - Persist coins, inventory, passes, credits, exports, or downloads in your own backend storage.
- Expose an authenticated inventory/status endpoint to your frontend.
Keep zpk_... API keys and webhook signing secrets in server environment variables only. Never put them in game.js, browser code, mobile code, or a public repository.
10Core Objects
- App: a game or application with a Base USDC receive address.
- Product: a paid action, SKU, credit pack, unlock, export, or usage unit.
- Order: optional backend-created payment intent.
- Charge: ZentPay ledger record for a paid action.
- Delivery: the fulfillment record your backend should trust.
- Webhook Delivery: an outgoing webhook attempt and retry record.
- API Key: backend-only secret used for orders, receipts, and delivery lookup.
- Authorization: the user's Permit-backed budget for BudgetSpenderV2.
11Default Product Templates
Keep product names clear and product-like. The code and API should talk about zent, paid actions, products, orders, and deliveries.
| Template | Price |
|---|---|
| zent | 0.01 USDC |
| Retry / Continue | 0.01 USDC |
| Unlock Level | 0.03 USDC |
| Generate Once | 0.05 USDC |
| Export / Download | 0.10 USDC |
| Tip Creator | custom amount |
USDC uses 6 decimals on Base Sepolia and Base mainnet. priceAtomic is the integer USDC amount in atomic units:
$0.01 USDC = 0.01 * 1,000,000 = 10000
$1.00 USDC = 1,000,000
Use the CLI helper to avoid decimal mistakes:
npx zentpay price "$0.01"
npx zentpay price --atomic 10000
12Dev Console Setup
- Register or log in at
https://dev.zentpay.appwith email/username and password. Registration requires email, username, password, and an email verification code; the resend button is rate-limited to 60 seconds. - Create an app. App creation is a technical setup step, not a blocking review. If you already know the Base USDC receive address, set it as
pay_to; otherwise the app starts withpay_to_status=unboundand can be completed later. Binding or changingpay_tostarts ZentPay's review for Portal backing and public listing; it does not grant or revoke the address's ability to receive payments. - Create one or more products. A product receives a hosted payment route shaped like
/api/pay/<app-slug>/<product-slug>. - Create a webhook endpoint for fulfillment.
- Use
Test Webhookto confirm raw-body HMAC verification and idempotent fulfillment before real traffic. - Create a secret API key if your backend needs to create orders or query delivery and receipt state. The full secret is shown once; later UI displays only a masked preview with a copy button for newly created secrets.
- Use the Agent Panel to grant short-lived CLI access and copy CLI commands,
.env.local,zentpay.toml, product JSON, and webhook test payloads into your codebase or AI coding agent. - Run one real test payment before production traffic.
- Review Dev Console go-live readiness. Treat it as a production loop, not a configuration checklist: signed webhook live test, backend key scopes, Runtime manifest and proxy path, inventory sync endpoint, and durable fulfillment ledger /
paymentProofgrant path. - If using Worker Quick Launch, save and verify Runtime origin, then request the runtime domain.
- Use Recent Deliveries, Recent Orders, Webhook Deliveries, and retry controls to inspect fulfillment and retry failed webhook attempts.
Never put API keys, webhook signing secrets, admin credentials, relayer keys, or recipient allowlist owner keys in browser, game client, mobile, Telegram, or Farcaster frontend code.
12.1Create App Fields
| Field | How To Fill |
|---|---|
| App name | Public display name, for example Gemix. The app slug is generated from this name. |
| Type | Game for playable experiences; Application for tools, AI utilities, SaaS, marketplaces, or non-game apps. |
| Headline | One short public line shown on the official entry page. |
| Entry URL | Developer-owned public URL, for example https://game.example.com/play. If the developer has no domain yet, leave it blank and use Worker Runtime later. |
| Pay to | Optional at app creation. A Base USDC recipient wallet starts as pay_to_status=testing; pay_to_status=approved is required only for ZentPay Portal backing and public listing. |
| Distribution targets | Where users will open the app: Web, PWA, Telegram, Farcaster, or Base. This is metadata, not a hosting switch. |
| Tags | Comma-separated labels, for example Game, Items, USDC. |
For developers with their own domain, put that domain in Entry URL. Do not put their own domain in Runtime. Runtime origin is only for Worker Quick Launch when the developer wants ZentPay to verify and route a Cloudflare Worker at https://<app-slug>.apps.zentpay.app.
12.2CLI / Agent Path
The Dev Console API is also exposed through the zentpay CLI for AI coding agents and local setup scripts. Prefer browser or device authorization so the developer approves a short-lived, app-scoped CLI grant from the browser where they are already logged into Dev Console. Do not give an agent a password, admin credential, long-lived developer session, webhook secret, or API key.
For a Next.js App Router backend, start with the generated template:
npm install -D @zentpay/x402-pay@0.2.12
npx zentpay init next --app gemix --env .env.local
npx zentpay auth browser --app gemix --scopes apps:read,apps:write,products:write,webhooks:write,runtime:write,keys:create --ttl 2h
npx zentpay config plan --file zentpay.config.json --mode mirror --json
npx zentpay config apply --file zentpay.config.json --mode mirror --yes --json
npx zentpay keys create --app gemix --label "Backend key" --scopes orders:create,orders:read,charges:create,deliveries:read,receipts:read,connections:create,connections:read --env .env.local
The template creates:
app/api/zentpay/webhook/route.tsapp/api/zentpay/payment-submitted/route.tswhen your app uses a dedicated callback recovery endpointapp/api/zentpay/reconcile/route.tsapp/api/inventory/route.tsapp/api/inventory/sync/route.tsapp/.well-known/zentpay.json/route.tssrc/lib/zentpay.tstests/zentpay.webhook.test.tszentpay.config.json.env.local
The generated Next.js routes are app-owned adapter stubs. src/lib/zentpay.ts must be connected to your durable fulfillment store/DB before production. The webhook route calls handleZentPayWebhook(), the reconcile route can use verifyPaymentProofAndGrantOnce() or reconcileDeliveryAndGrant(), and inventory sync only refreshes server-confirmed inventory. A browser return URL, success flag, or postMessage callback must never grant inventory by itself. The starter product config uses a proof-first trusted-server charge posture and keeps the webhook as the reconciliation path, not the only fulfillment path.
npm install -D @zentpay/x402-pay@0.2.12
npx zentpay auth browser --app gemix --scopes apps:read,apps:write,products:write,webhooks:write,runtime:write,keys:create --ttl 2h
# or:
npx zentpay auth device --app gemix --scopes apps:read,apps:write,products:write,webhooks:write,runtime:write,keys:create
npx zentpay auth status --json
npx zentpay config init --app gemix --file zentpay.config.json
npx zentpay config plan --file zentpay.config.json --mode merge --json
npx zentpay config apply --file zentpay.config.json --mode merge --yes --json
npx zentpay config revisions --app gemix --json
npx zentpay app create --name gemix --url https://game.example.com --pay-to 0x...
npx zentpay app update --app gemix --url https://game.example.com --pay-to 0x...
npx zentpay app upsert --slug gemix --name gemix --pay-to 0x...
npx zentpay product list --app gemix --json
npx zentpay product diff --app gemix --file products.json
npx zentpay product upsert --app gemix --file products.json --match sku --json --yes
npx zentpay product update --app gemix --product zprod_... --name "600 Diamonds" --sku gemix_diamonds_600 --price "$0.50" --template custom --risk optimistic --fulfillment webhook --fulfillment-config '{"sku":"gemix_diamonds_600","reward":{"type":"currency","currency":"diamonds","quantity":600}}'
npx zentpay product create --app gemix --name "AI Usage" --price "$0.01" --pricing dynamic_order --min-amount "$0.01" --max-amount "$5.00"
npx zentpay order create --product zprod_... --amount "$0.37" --idempotency usage:game-user-123:req-456 --developer-user game-user-123 --json
npx zentpay price "$0.01"
npx zentpay webhook upsert --app gemix --url https://game.example.com/api/zentpay/webhook --events payment.delivered
npx zentpay webhook list --app gemix --failed --json
npx zentpay webhook update --webhook zwh_... --url https://game.example.com/api/zentpay/webhook --events payment.delivered
npx zentpay webhook rotate --webhook zwh_... --env .env.local
npx zentpay webhook retry --event evt_... --webhook zwh_... --yes --json
npx zentpay keys list --app gemix --json
npx zentpay keys create --app gemix --label "Backend key" --scopes orders:create,orders:read,charges:create,deliveries:read,receipts:read,connections:create,connections:read --env .env.local
npx zentpay keys update --key zpk_... --label "Backend key"
npx zentpay keys revoke --key zpk_...
npx zentpay keys pull --app gemix --env .env.local # writes config comments only; masked keys are not usable secrets
npx zentpay export --app gemix --format dotenv,toml,products-json
npx zentpay doctor --app gemix --json
CLI grants are scoped to one app, expire by default, and can be revoked with zentpay auth revoke or zentpay auth logout. Use the smallest scopes needed: apps:write for app metadata, products:write for catalog changes, webhooks:write for webhook setup, keys:create only when generating backend keys, and runtime:write only for Worker Runtime configuration. CLI grants cannot run settlement operations, pay_to review, Portal listing review, recipient allowlist owner operations, or admin-token tasks. Backend API keys are separate runtime secrets and should remain server-side only. zentpay config plan is the agent-safe preview path; zentpay config apply --yes is the repeatable apply path that records revision history. auth status verifies the saved grant against the API; if the grant is expired, app-scoped to the wrong app, or missing a required scope, the CLI returns the exact auth browser command to run again.
Authorization diagnostics
auth status showing a valid grant means the CLI control-plane grant is valid for the app and scopes shown by the command. If config plan, config apply, product, webhook, runtime, or keys create fails with developer_cli_scope_missing, run the exact zentpay auth browser command returned by the CLI and include the missing scope, for example keys:create.
If account handoff, order creation, delivery lookup, or receipt lookup fails with valid_api_key_required or api_key_scope_missing, create or rotate a server-side zpk_... backend key with the runtime scopes named in the error. The browser grant does not replace ZENTPAY_API_KEY; it only lets the CLI configure Console resources. keys pull writes masked guidance only; masked keys are not usable secrets. Use zentpay keys create --env .env.local when a developer machine needs a usable backend secret, and store the full value when it is shown once.
12.3Config-as-Code + Console
Dev Console now supports a single zentpay.config.ts or zentpay.config.json shape for repeatable configuration. The config file is intentionally public-ish project metadata: it can describe app, products, webhooks, runtime, metadata, and environment, but it must not contain API key secrets, webhook signing secrets, admin credentials, relayer keys, private keys, tokens, or passwords.
Minimal JSON example:
{
"schemaVersion": "zentpay.config.v1",
"environment": "test",
"app": {
"slug": "gemix",
"name": "Gemix",
"kind": "game",
"entryUrl": "https://game.example.com/play",
"payTo": "0x0000000000000000000000000000000000000000",
"distributionTargets": ["Web"],
"tags": ["Game", "USDC"]
},
"products": [
{
"slug": "continue",
"sku": "gemix_continue",
"name": "Continue",
"priceAtomic": "10000",
"riskMode": "optimistic",
"riskPolicy": {
"trustedServerCharge": {
"enabled": false,
"paymentProofRequired": true,
"maxAmountAtomic": null
}
},
"fulfillmentAdapter": "webhook",
"fulfillmentConfig": { "sku": "gemix_continue" }
}
],
"webhooks": [
{
"url": "https://game.example.com/api/zentpay/webhook",
"events": ["payment.delivered"]
}
],
"runtime": {
"mode": "worker_quick_launch",
"origin": "https://gemix-worker.example.workers.dev"
},
"metadata": {}
}
The primary CLI/agent entrypoint is zentpay config. It calls the same Config-as-Code API that powers the Console Config tab. In Console, use Agent / CLI for browser authorization and copy-ready CLI commands; use Config for current-vs-desired diff, plan summary, secret gaps, revision history, and rollback.
# 1. Install the CLI package. Version 0.2.12 contains trusted-server charge
# helpers and config plan/apply.
npm install -D @zentpay/x402-pay@0.2.12
# 2. Create a local config file. You can also copy zentpay.config.json from
# Dev Console -> Agent / CLI Configuration.
npx zentpay config init --app gemix --file zentpay.config.json
# 3. Authorize the CLI. Existing apps can use a short-lived Console grant.
# New app creation should use `zentpay login` or create the app in Console
# first, then grant the CLI access to that app.
npx zentpay auth browser \
--app gemix \
--scopes apps:read,apps:write,products:write,webhooks:write,runtime:write,keys:create \
--ttl 2h
# 4. Always plan first. This returns changes, blocking errors, warnings,
# missing secrets, and runtime validation inputs.
npx zentpay config plan --file zentpay.config.json --mode merge --json
# 5. Apply only after reviewing the plan. Apply creates a revision row and
# returns any newly generated webhook signing secret once.
npx zentpay config apply --file zentpay.config.json --mode merge --yes --json
# 6. Inspect history or rollback a previous applied revision.
npx zentpay config revisions --app gemix --json
npx zentpay config rollback --revision zcfgrev_... --yes --json
zentpay config apply sends the config to POST /api/developer/config/apply; it does not use the older per-resource zentpay configure path. The legacy zentpay configure --config zentpay.toml --products products.json command still exists for old integrations, but new CLI agents should prefer zentpay.config.json plus zentpay config plan/apply.
The Console Config tab shows the current config, desired config, plan diff, apply history, failed revision reason, missing secret requirements, and runtime verification status. Newly created webhook signing secrets are returned once at apply time and are not saved in the config file or revision history. config apply --json intentionally returns a compact payload with ops, summary, ids, warnings, and one-time secrets. Use --verbose only when you need the full platform snapshot for debugging.
The developer API endpoints are:
POST /api/developer/config/plan
POST /api/developer/config/apply
GET /api/developer/config/revisions?app=<app-id-or-slug>
POST /api/developer/config/rollback
mode=merge changes only declared resources. mode=mirror also disables products and webhooks that are currently in Console but absent from the desired config. CLI grants use the existing scoped permissions in the plan: apps:write, products:write, webhooks:write, and runtime:write. merge leaves undeclared active products or webhooks unchanged and plan output warns about those leftovers; use mirror when the config file should be the complete source of truth.
Console separates the workflow into two visible surfaces:
Agent / CLI: authorize a local agent with a browser-approved, app-scoped grant; copyzentpay.config.json; copy the install/auth/plan/apply command sequence.Config: edit the desired config, compare it with the current Console state, inspect warnings and missing secrets, apply the change, then review revision history or rollback.
The Dev Console Agent panel exports copy targets that are meant to round trip through the CLI:
CLI commands: the short-lived auth, Config Plan, Config Apply, revision history, webhook test, key, runtime, doctor, and export commands for the selected app.zentpay.config.json: the preferred Config-as-Code input forzentpay config plan/apply.zentpay.toml: the local project mapping. The CLI readsapi_url,app,app_slug,entry_url,pay_to,webhook_url,products_file, and[runtime].originautomatically.products.json: the legacy product catalog input forzentpay product upsertand oldzentpay configureflows.- backend
.env: server-only values for API URL, app id/slug, runtime URLs, API key preview, and webhook secret preview.
API key creation still stays explicit through zentpay keys create because full secrets are shown only once.
For ZentPay Runtime:
zentpay app create --name gemix --runtime worker --slug gemix --pay-to 0x...
zentpay runtime configure --app gemix --origin https://gemix-worker.example.workers.dev
zentpay runtime verify --app gemix
zentpay runtime request-domain --app gemix
zentpay runtime disable --app gemix
12.4Other Create Forms
| Form | Required Information |
|---|---|
| Product | App, product name, USDC price, template, risk mode, fulfillment adapter, and JSON fulfillment config. |
| Webhook | App and HTTPS POST endpoint. The endpoint must verify x-zentpay-signature from the raw request body before granting items or credits. |
| API key | Scope, label, and environment. The full zpk_... secret is shown once and must stay on the backend. |
| Runtime | Optional Worker origin such as https://your-worker.workers.dev; Save + Verify runs manifest and endpoint checks, then Request Domain starts runtime hostname activation. |
13After App Approval
After ZentPay approves the pay_to + app + developer combination, the app gets pay_to_status=approved. This is Portal backing and listing eligibility, not a payment-recipient permission. Portal public entry still requires portal_listing_status=listed. Runtime domain approval remains separate and is only needed when the app uses Worker Quick Launch.
The examples in this section use gemix as a placeholder app slug because it is a ZentPay test game. When integrating your own app, replace every gemix value with your own app slug, replace https://gemix.apps.zentpay.app with your own runtime URL if you use Worker Quick Launch, replace <product-slug> with the actual product slug copied from Dev Console, and replace sample user IDs, function names, element IDs, and source labels with names from your app.
For an approved app like gemix, the public URLs are:
Official Entry: https://portal.zentpay.app/gemix
Runtime URL: https://gemix.apps.zentpay.app
Payment API: https://api.zentpay.app
Use https://portal.zentpay.app/gemix when you want ZentPay to handle the official profile, Portal authorization, and built-in product buttons. Use https://gemix.apps.zentpay.app as the actual app/game runtime only after Runtime status is active.
13.1Developer Checklist
- Confirm the app row in Dev Console:
Statusisactive;payToStatusisapproved;portalListingStatusislistedbefore using Portal discovery;Runtime statusisactiveif usinghttps://gemix.apps.zentpay.app.
- Create or review products. Copy the product route shown in the Products table, for example:
https://api.zentpay.app/api/pay/gemix/<product-slug>
- Create a webhook endpoint. For Worker Quick Launch, use:
https://gemix.apps.zentpay.app/api/zentpay/webhook
This apps.zentpay.app URL is ZentPay's reviewed runtime proxy, not the developer-owned origin. It is valid only for the same app slug, the exact /api/zentpay/webhook path, and only after Runtime status is active. Do not use api.zentpay.app, portal.zentpay.app, docs.zentpay.app, a different app slug, or any other ZentPay-owned host as a webhook endpoint.
During early testing, before the runtime hostname is active, use the developer Worker origin instead:
https://<developer-worker>.workers.dev/api/zentpay/webhook
- Store the webhook signing secret returned by Dev Console as a server-side secret:
ZENTPAY_WEBHOOK_SECRET=zwhsec_...
- Click
Test Webhook. The endpoint should return 2xx only after it verifiesx-zentpay-signatureand records an idempotent fulfillment bydeliveryIdoridempotencyKey. - Run one live signed webhook test with a real payment before production traffic. Dev Console should show a delivered
payment.deliveredattempt in Webhook Deliveries and a matching delivery record. If the webhook probe or payment webhook returns 404, deploy a realPOST /api/zentpay/webhookroute on the developer-owned backend origin, or use the exacthttps://gemix.apps.zentpay.app/api/zentpay/webhookproxy path only after Runtime status isactive. - Create an API key only if the backend will create orders or query private delivery/receipt state. Store it server-side:
ZENTPAY_API_KEY=zpk_test_...
Use a server-only Backend key with the scopes required by your path. For the recommended production backend key, include orders:create,orders:read,charges:create,deliveries:read,receipts:read,connections:create,connections:read. If an API call returns 401 or 403, create or rotate the key with the missing scope and deploy it only to the backend.
- Run one real test payment from Hosted Entry or the runtime app. Then check:
- Recent Deliveries has a new delivery;
- Webhook Deliveries shows a delivered attempt;
- Recent Orders stays
0unless the backend calledPOST /api/orders; - the user's remaining authorization balance changed.
- Implement the app-owned inventory sync endpoint before go-live. A common shape is an authenticated
POST /api/inventory/syncroute that reads the durable fulfillment ledger, refreshes the user's coins/items/passes, and is called after app launch, after Portal return, after webhook retry, and during support recovery. ZentPay does not provide this app endpoint for you. - Confirm the durable fulfillment ledger and
paymentProofgrant path. Grant once bydeliveryIdoridempotencyKey; for trusted-server charges, verifypaymentProofbefore immediate grant, then reconcile the signedpayment.deliveredwebhook.
13.2Worker Quick Launch Environment
For the Worker starter, set these values in the developer Worker, not in frontend code:
ZENTPAY_APP_ID = "zapp_..."
ZENTPAY_APP_SLUG = "gemix"
ZENTPAY_PAY_TO = "0x..."
ZENTPAY_RUNTIME_ORIGIN = "https://<developer-worker>.workers.dev"
ZENTPAY_RUNTIME_APP_PATH = "/"
ZENTPAY_WEBHOOK_SECRET = "zwhsec_..."
ZENTPAY_API_KEY = "zpk_test_..." # optional, backend-only trusted charge/order/delivery flow
In your deployment, ZENTPAY_APP_SLUG must be your Dev Console app slug, not gemix. The same replacement rule applies to every URL, idempotency key prefix, source label, and code sample below.
ZENTPAY_RUNTIME_ORIGIN remains the developer Worker origin that was saved in Dev Console and verified by /.well-known/zentpay.json. It is not https://gemix.apps.zentpay.app; the apps.zentpay.app URL is ZentPay's reviewed proxy in front of that origin.
ZENTPAY_RUNTIME_APP_PATH defaults to / and should stay a concrete path. Set it to /play, /app, or another runtime route only when full redirect or new-window authorization returns should land there. Do not set it to null.
13.3Purchase Integration Choices
For the first launch, the simplest path is to send users to the Official Entry:
<a href="https://portal.zentpay.app/gemix">Buy with ZentPay</a>
Hosted Entry already handles Portal authorization, calls the product route, and updates the budget summary in the browser.
If the runtime app wants an in-game purchase button, prefer the ZentPay runtime client. It opens Portal authorization inside an in-page modal iframe and stores the returned authorization summary on the runtime origin. The SDK does not open a browser tab/window for the default authorization path:
<span id="zentpay-wallet-profile"></span>
<script src="https://gemix.apps.zentpay.app/zentpay-client.js"></script>
<script>
const zentpay = window.ZentPayGameClient.create({
appSlug: "gemix",
developerUserId: "game-user-123"
});
zentpay.mountWalletProfile("#zentpay-wallet-profile");
async function buy(product) {
if (!zentpay.getAuth()) {
await zentpay.authorize();
}
return zentpay.purchase(product, { screen: "shop" });
}
</script>
14Account Binding And One-Click Payment
ZentPay does not replace the game's login system. The game account remains owned by the developer, and ZentPay binds that account to the user's payment wallet plus global BudgetSpender authorization:
Game User ID <-> ZentPay Account Connection <-> Wallet / Email / Global Budget
Recommended Account Binding sequence:
sequenceDiagram
participant Game as Game frontend
participant Backend as Game backend
participant ZentPay as ZentPay API / Portal
participant Webhook as Game webhook
Game->>Backend: Current game account session
Backend->>ZentPay: POST /api/account-handoffs with merchantUserId
ZentPay-->>Backend: ready or portalUrl
Backend-->>Game: Client-safe handoff state
Game->>ZentPay: Open portalUrl when connection or budget is needed
ZentPay-->>Game: Return summary for wallet, budget, accountConnectionId
Game->>ZentPay: Exchange authorizationCode for product-bound payment code
Game->>ZentPay: Call product route with payment code
ZentPay-->>Webhook: payment.delivered
Webhook-->>Backend: Grant entitlement once by deliveryId or idempotencyKey
Use merchantUserId as the stable developer-side game account id. Use gameAccountLabel only as a human-readable display name. authorizationCode is short-lived. The handoff summary code must be exchanged for a product-bound payment code, and the derived product payment code is single-use. accountConnectionId can be reused for the same app/game account after a ready handoff, but refresh it after unlink, wallet switch, budget renew, or a payment code mismatch.
Use this path for in-game UX. The developer backend creates a short-lived account handoff for the current logged-in game account. If a connection already exists and the connected wallet still has active budget, ZentPay returns a ready summary authorization immediately. The user does not click "restore" and does not see a second login step; the game exchanges the summary code for a product-bound payment code before calling the product route.
The first app creates a connection for that app/game account. Later apps can use the same global wallet budget after their own connection is created; they do not need a new ZentPay login or new permit until the budget is spent, expires, is revoked, or the user unlinks the app connection.
import { createZentPayAccountHandoff } from "@zentpay/x402-pay/server";
const handoff = await createZentPayAccountHandoff({
appSlug: "gemix",
merchantUserId: currentUser.id,
gameAccountLabel: currentUser.displayName,
emailHint: currentUser.email,
returnUrl: "https://gemix.apps.zentpay.app/zentpay-auth-return?app=gemix",
apiKey: process.env.ZENTPAY_API_KEY!
});
if (handoff.status === "ready" && handoff.authorization) {
return {
ownerWallet: handoff.authorization.ownerWallet,
spender: handoff.authorization.spender,
authorizationCode: handoff.authorization.authorizationCode,
accountConnectionId: handoff.authorization.accountConnectionId
};
}
return { portalUrl: handoff.handoff.portalUrl };
authorization.authorizationCode is server-only unless the browser is about to exchange it from the approved app origin. Prefer returning a client-safe whitelist to the frontend:
import {
createZentPayAccountHandoff,
toClientSafeAccountHandoff
} from "@zentpay/x402-pay/server";
const handoff = await createZentPayAccountHandoff({ /* ... */ });
return Response.json(toClientSafeAccountHandoff(handoff));
Client-safe fields are status, handoff.portalUrl, handoff.expiresAt, handoff.appSlug, handoff.gameAccountLabel, connection.id, connection.status, connection.ownerWallet, connection.budgetRemaining, and connection.boundAt. Do not return authorization.authorizationCode to a general UI state endpoint, analytics event, log stream, or browser cache.
14.1Pending Purchase Recovery
When a user starts authorization during a purchase, save the purchase intent before opening Portal. A reload, full redirect, popup close, mobile browser handoff, or WalletConnect round trip must be able to resume the exact same intent without trusting URL parameters as payment state.
Store only the fields needed to recover the purchase: the product/order/idempotencyKey/developerUserId/accountConnectionId tuple and the creation time.
The recovery backend may use trusted delivery lookup or verified paymentProof, but the return URL is only a signal to refresh account state and resume the saved intent.
type PendingZentPayPurchase = {
productSlug: string;
orderId?: string;
idempotencyKey: string;
developerUserId: string;
accountConnectionId?: string;
createdAt: number;
};
Reference browser implementation:
const PENDING_PURCHASE_KEY = "zentpay:pending-purchase:v1";
function savePendingPurchase(intent: PendingZentPayPurchase) {
localStorage.setItem(PENDING_PURCHASE_KEY, JSON.stringify(intent));
}
function readPendingPurchase(): PendingZentPayPurchase | null {
const raw = localStorage.getItem(PENDING_PURCHASE_KEY);
if (!raw) return null;
try {
const intent = JSON.parse(raw);
if (!intent.productSlug || !intent.idempotencyKey || !intent.developerUserId) {
clearPendingPurchase();
return null;
}
return intent;
} catch {
clearPendingPurchase();
return null;
}
}
function clearPendingPurchase() {
localStorage.removeItem(PENDING_PURCHASE_KEY);
}
async function recoverPendingPurchaseFromReturnUrl(url: URL) {
const pending = readPendingPurchase();
const code = url.searchParams.get("code");
if (!pending || !code) return null;
// The return code is transport only. Never treat it as proof that payment is
// ready until ZentPay returns a fresh summary for this app origin.
const summaryResponse = await fetch(
`https://api.zentpay.app/api/authorizations/summary?code=${encodeURIComponent(code)}`,
{ headers: { accept: "application/json" } },
);
if (!summaryResponse.ok) {
clearPendingPurchase();
return { status: "unrecoverable" as const };
}
const summary = await summaryResponse.json();
// Refresh the developer backend handoff/account state after summary. This
// verifies the current game account, connection, wallet, and budget state
// instead of trusting code, authorization=ready, or account ids in the URL.
const handoffResponse = await fetch("/api/zentpay/account-handoff", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ developerUserId: pending.developerUserId }),
});
const handoff = handoffResponse.ok ? await handoffResponse.json() : null;
const accountConnectionId =
summary.accountConnectionId ||
handoff?.connection?.id ||
pending.accountConnectionId;
if (!accountConnectionId) {
clearPendingPurchase();
return { status: "unrecoverable" as const };
}
const canUseTrustedCharge =
handoff?.status === "ready" &&
handoff?.connection?.id === accountConnectionId &&
pending.accountConnectionId === accountConnectionId;
if (canUseTrustedCharge) {
const chargeResponse = await fetch("/api/zentpay/trusted-charge", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
productSlug: pending.productSlug,
orderId: pending.orderId,
idempotencyKey: pending.idempotencyKey,
developerUserId: pending.developerUserId,
accountConnectionId,
}),
});
if (chargeResponse.ok) clearPendingPurchase();
return chargeResponse;
}
const paymentCodeResponse = await fetch("https://api.zentpay.app/api/authorizations/payment-code", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
authorizationCode: summary.authorizationCode,
paymentUrl: `https://api.zentpay.app/api/pay/gemix/${encodeURIComponent(pending.productSlug)}`,
orderId: pending.orderId,
accountConnectionId,
developerUserId: pending.developerUserId,
idempotencyKey: pending.idempotencyKey,
}),
});
if (!paymentCodeResponse.ok) {
clearPendingPurchase();
return { status: "unrecoverable" as const };
}
const { authorization } = await paymentCodeResponse.json();
const paymentResponse = await fetch(
`https://api.zentpay.app/api/pay/gemix/${encodeURIComponent(pending.productSlug)}`,
{
method: "POST",
headers: {
"content-type": "application/json",
"x-wallet-address": summary.ownerWallet,
"x-zentpay-budget-spender": summary.spender,
},
body: JSON.stringify({
authorizationCode: authorization.authorizationCode,
orderId: pending.orderId,
accountConnectionId,
developerUserId: pending.developerUserId,
idempotencyKey: pending.idempotencyKey,
source: "gemix-runtime-recovery",
}),
},
);
if (paymentResponse.ok) {
clearPendingPurchase();
return paymentResponse;
}
const paymentError = await paymentResponse.clone().json().catch(() => null);
if (isUnrecoverablePaymentError(paymentError?.code)) {
clearPendingPurchase();
}
return paymentResponse;
}
function isUnrecoverablePaymentError(code?: string) {
return [
"AUTHORIZATION_CODE_CONSUMED",
"AUTHORIZATION_CODE_INTENT_MISMATCH",
"ACCOUNT_CONNECTION_MISMATCH",
].includes(code || "");
}
Save the intent before opening Portal:
savePendingPurchase({
productSlug: "extra-moves-5",
orderId: currentOrder?.id,
idempotencyKey: `gemix:${currentUser.id}:extra-moves-5:${requestId}`,
developerUserId: currentUser.id,
accountConnectionId: currentConnection?.id,
createdAt: Date.now()
});
After recovery, continue either the browser payment-code product route or the trusted-server charge path for the same product/order/idempotency key. Clear the pending intent after a successful charge, after a stable unrecoverable business error such as consumed code, intent mismatch, or connection mismatch, or when the summary/handoff cannot be refreshed. Keep it for recoverable budget-renewal and retryable network or ledger-unavailable failures where no entitlement was granted.
REST equivalent:
curl https://api.zentpay.app/api/account-handoffs \
-H "authorization: Bearer zpk_test_..." \
-H "content-type: application/json" \
-d '{
"appSlug": "gemix",
"merchantUserId": "game-user-123",
"gameAccountLabel": "GemixPlayer#1234",
"emailHint": "player@example.com",
"returnUrl": "https://gemix.apps.zentpay.app/zentpay-auth-return?app=gemix"
}'
The response status tells the runtime what to do:
ready: useauthorization.authorizationCode,ownerWallet,spender, andaccountConnectionIdto create a product-bound code at/api/authorizations/payment-codefor the next product route call.needs_connection: openhandoff.portalUrl; Portal will show "Connect payment" and bind the current game account to the ZentPay payment wallet.needs_budget: the game account is linked, but the wallet needs a fresh budget. Openhandoff.portalUrlto renew.
The summary authorizationCode is short-lived, and each derived payment code is single-use. Refresh the handoff on app launch, before a purchase when local state is missing or stale, after a successful purchase, and when a budget error says the code, budget, or connection no longer matches. Do not use the Portal login session itself as the game's payment state. Portal sign-in only identifies the current payment wallet; the game should treat ready handoff responses as the source of truth for one-click purchase readiness.
The /api/authorizations/payment-code exchange is an app-origin browser boundary. Call it from the Hosted Entry or approved runtime origin that received the summary authorizationCode. Requests without an Origin header, or with an origin that does not match the summary audience, are rejected; do not move this exchange to a no-origin backend job.
If the runtime has just received a valid Portal return summary from /zentpay-auth-return, use that authorizationCode, ownerWallet, spender, and accountConnectionId for the immediate purchase before refreshing the handoff. A temporary needs_connection handoff response must not override a fresh Portal return summary; it should only open Portal again when no valid local return summary exists or the payment API rejects the summary as expired, consumed, or mismatched.
Switch wallet changes the Portal payment wallet for the current authorization flow. It does not unlink the game account by itself. If the user completes the flow, the same game account connection moves to the newly authorized wallet. If the user cancels, the previous game account connection can remain active until the user unlinks it or revokes the global budget. After any switch, disconnect, purchase, or budget error, refresh the handoff before the next charge.
Wallet card status should describe the current payment state, while the primary button should describe the next action. Do not use Connect ZentPay, Link ZentPay, Disconnected, or Not connected for the main payment state; those labels make users think their ZentPay login failed. Use this mapping:
| Handoff / runtime state | Meaning | Card status | Primary button | Top pill | Pill color |
|---|---|---|---|---|---|
checking | Handoff or summary is loading. | Checking payment | Checking | ZentPay · Checking | Muted gray. |
game_login_needed | The developer game account is not logged in. | Game login needed | Log in | ZentPay · Log in | Neutral gray. |
needs_connection | This game account has not enabled ZentPay payment. | Payment off | Enable payment | ZentPay · Enable payment | Teal primary. |
opening_portal | Portal handoff is opening. | Opening Portal | Opening | ZentPay · Opening | Teal busy. |
ready | Connection exists and global budget has remaining balance. | Payment ready | Manage | ZentPay · 4.55 USDC | Success green / teal. |
needs_budget | Connection exists, but budget is missing or low. | Budget low | Renew budget | ZentPay · Renew budget | Amber. |
budget_expired | The permit-backed budget expired. | Budget expired | Renew budget | ZentPay · Renew budget | Amber. |
budget_revoked | The user revoked the ZentPay budget. | Budget revoked | Renew budget | ZentPay · Renew budget | Amber. |
sync_failed | The app could not refresh handoff or budget summary. | Sync failed | Try again | ZentPay · Try again | Red. |
unavailable | ZentPay API or payment ledger is unavailable. | Unavailable | Try again | ZentPay · Unavailable | Muted red or gray. |
The top pill should be compact and stateful. Use color plus text, not color alone. Recommended color roles:
- Teal primary for
Enable paymentandOpening. - Success green / teal for
Payment readyand the balance pill. - Amber for
Renew budget,Budget low,Budget expired, andBudget revoked. - Red for
Sync failed/Try again. - Muted gray for
Checking,Log in, andUnavailable.
When charging with a ready handoff, exchange the ready summary code for a product-bound payment code first, then pass the connection id back to the product route. ZentPay rejects mismatches between the payment code and the requested connection.
const idempotencyKey = `gemix:${productSlug}:${crypto.randomUUID()}`;
const codeResponse = await fetch("https://api.zentpay.app/api/authorizations/payment-code", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
authorizationCode: ready.authorizationCode,
paymentUrl: "https://api.zentpay.app/api/pay/gemix/<product-slug>",
accountConnectionId: ready.accountConnectionId,
developerUserId: currentUser.id,
idempotencyKey
})
});
const paymentCode = (await codeResponse.json()).authorization.authorizationCode;
await fetch("https://api.zentpay.app/api/pay/gemix/<product-slug>", {
method: "POST",
headers: {
"content-type": "application/json",
"x-wallet-address": ready.ownerWallet,
"x-zentpay-budget-spender": ready.spender
},
body: JSON.stringify({
authorizationCode: paymentCode,
accountConnectionId: ready.accountConnectionId,
developerUserId: currentUser.id,
idempotencyKey,
source: "gemix-runtime"
})
});
Security rules:
- Create account handoffs only from the developer backend with a secret API key.
- Do not create real-money account handoffs from browser-only code. A no-backend product can be a demo, not a production USDC fulfillment flow.
- Use a stable per-app
merchantUserId/game_user_id; ZentPay stores a scoped hash, not the raw id. - Do not use a shared test id such as
game-user-123for multiple real users. A sharedmerchantUserIdrepresents one developer-side account and can make different browser profiles intentionally resolve to the same game account. emailHintis a display and matching hint only. Do not treat it as proof that the developer login and ZentPay wallet are the same identity.- To use the
readyone-click path safely, include a stable developer-side identity hint such asemailHintfor logged-in accounts, orwalletHintwhen your app already knows the intended wallet. ZentPay does not auto-return a previous wallet connection for a bare/sharedmerchantUserIdhandoff. - A connection is not a budget by itself. The direct path is only ready when the connected wallet also has active remaining budget.
- Users can manage app/game connections at
https://portal.zentpay.app/account. Account shows Global Budget, Connected Apps, Ready Apps, spend records, unlink, renew, and revoke controls. Unlinking stops game-account reuse but does not silently revoke the underlying wallet budget. - Account hides connected app/game records until the user clicks
Verify walletand signs one wallet proof. The signature proves ownership for account management; it is not a payment permit, costs no gas, and can be cached for the current browser session. - Keep user-facing actions distinct:
Disconnect: clears the current Portal/app wallet session and, when the wallet supports it, asks the extension to forget this site.Unlink: removes one app/game account connection. Other apps can still use the global budget.Revoke: stops the global budget for all connected apps by signingPermit(value=0).
14.2Binding Bonus Pattern
Games often grant a one-time reward when a player connects ZentPay. Use the account connection as a signal, but keep the bonus idempotent in your backend. Store a durable replay guard keyed by the account connection, and only grant the bonus when that guard does not already exist. If the bonus depends on a paid purchase, grant it from payment.delivered instead and still dedupe by deliveryId.
After ZentPay is connected, apps should keep a small wallet surface visible in the app UI. The recommended minimal pattern is a collapsed pill such as ZentPay 4.55 USDC that expands into a compact wallet card and can be collapsed again.
The collapsed pill should show the current payment state, not a login state:
ZentPay 4.55 USDCwhen the handoff isready.ZentPay · Enable paymentwhen the game account still needs a connection.ZentPay · Renew budgetwhen the connection exists but budget is missing, spent, expired, or revoked.
The expanded minimal card should include only the high-frequency fields and actions:
Game account: status plus the current developer account, for exampleLogged inandGemixPlayer#1234.ZentPay: payment state, for examplePayment ready,Payment off, orBudget low.Payment wallet: shortened address with aCopyaction, for example0xaaf7...297d.USDC balance: the user's current USDC balance.Budget remaining: the ZentPay authorization ledgerremainingamount.Renew budget: opens Portal authorization withrenew=1orforceAuthorize=1.Sync: refreshes wallet balance and the ZentPay authorization summary.Manage: openshttps://portal.zentpay.app/account.
Keep account-management and destructive actions out of the minimal card. Unlink, Switch wallet, Revoke, and wallet Disconnect belong in Manage / Account Settings or a clearly confirmed advanced sheet. Forget is deprecated as a user-facing wallet action; if a runtime keeps it for debugging, it must only clear local cached authorization summary/session data and must not be presented as a normal returning-user path.
Do not display ERC-20 allowance as the spendable budget. In BudgetSpender flows, the user-facing value is remaining from the ZentPay summary/API because it reflects ledger spending. Runtime storage is useful for fast rendering, but refresh it after app launch, after a successful purchase, when the user clicks Sync, and before starting a purchase that may exceed the cached budget.
14.3Authorization Return Notification Modes
Use the notification pattern that matches how Portal was opened:
| Opening Pattern | Completion Signal | Recovery Rule |
|---|---|---|
| In-page iframe modal | Callback page sends postMessage to the parent page and can close/hide the modal. | Parent receives the message, calls /api/authorizations/summary, refreshes handoff/account state, then resumes the pending purchase. |
| Browser popup window | Callback page sends window.opener.postMessage; if the opener is unavailable, write a short status marker to runtime-origin storage and let the opener poll. | Opener must still call /api/authorizations/summary and refresh handoff/account state before spending. |
| Full redirect or new window | Callback page lands on the runtime entryPath / appPath with the short-lived code in the URL. | The app treats the URL code as transport only, calls /api/authorizations/summary, refreshes handoff/account state, then clears or resumes the pending purchase. |
mode=popup in the Portal authorize URL is a compact Portal layout parameter. It does not mean the SDK must call window.open(). The default runtime client loads that compact layout in an in-page iframe modal.
The underlying authorization-return protocol is:
- If there is no active authorization summary, open Portal in a modal iframe.
mode=popup&embed=1selects Portal's compact embedded layout; it is not a browserwindow.open()popup:
https://portal.zentpay.app/authorize?app=gemix&return=https%3A%2F%2Fgemix.apps.zentpay.app%2Fzentpay-auth-return%3Fapp%3Dgemix&mode=popup&embed=1
For a wallet-profile Renew button, add renew=1 or forceAuthorize=1 to that Portal URL and call the same in-page modal flow. Portal then skips the cached active-authorization return shortcut and asks the user to sign a fresh BudgetSpender permit before returning a new short-lived code:
https://portal.zentpay.app/authorize?app=gemix&return=https%3A%2F%2Fgemix.apps.zentpay.app%2Fzentpay-auth-return%3Fapp%3Dgemix&mode=popup&embed=1&renew=1
- When Portal redirects the iframe to
/zentpay-auth-return?code=..., the standard lightweight callback page is served by ZentPay onhttps://<app-slug>.apps.zentpay.app/zentpay-auth-return. Do not proxy this path to the full game route or replace it with a developer-owned page. The callback stores the returned summary on the runtime origin, sendspostMessageevents to the parent game, and closes the modal when possible. If you maintain an older custom runtime client, the parent can still exchange the code directly:
GET https://api.zentpay.app/api/authorizations/summary?code=<short-lived-code>
- Store the returned summary
authorizationCode,ownerWallet,spender, andremainingin runtime-origin storage and notify the game page withpostMessage. In the account binding flow, the summary may also includeaccountConnectionId. - On purchase, exchange the summary code for a product-bound, one-time payment code, then call the copied product route with that payment code:
const idempotencyKey = `gemix:${productSlug}:${crypto.randomUUID()}`;
const codeResponse = await fetch("https://api.zentpay.app/api/authorizations/payment-code", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
authorizationCode: summary.authorizationCode,
paymentUrl: "https://api.zentpay.app/api/pay/gemix/<product-slug>",
idempotencyKey,
accountConnectionId: summary.accountConnectionId,
developerUserId: "game-user-123"
})
});
const paymentCode = (await codeResponse.json()).authorization.authorizationCode;
await fetch("https://api.zentpay.app/api/pay/gemix/<product-slug>", {
method: "POST",
headers: {
"content-type": "application/json",
"x-wallet-address": summary.ownerWallet,
"x-zentpay-budget-spender": summary.spender
},
body: JSON.stringify({
authorizationCode: paymentCode,
accountConnectionId: summary.accountConnectionId,
developerUserId: "game-user-123",
idempotencyKey,
source: "gemix-runtime"
})
});
The frontend may show success or failure, but the trusted grant must come from the signed webhook or from a backend delivery lookup.
15Hosted Entry
If a developer does not have a domain, ZentPay can expose the app at:
https://portal.zentpay.app/<app-slug>
Hosted Entry is a portal app shell, not a second wallet system and not the app runtime origin. It reads public app and product config, then sends users to Portal when authorization is needed:
https://portal.zentpay.app/authorize?app=<app-slug>&return=https%3A%2F%2Fportal.zentpay.app%2F<app-slug>
After the user completes the Permit-backed budget authorization, Portal asks the wallet to sign a short owner proof bound to app, return URL, spender, and authorization id, then calls:
POST /api/authorizations/return-code
The API verifies the owner proof and returns a short-lived signed code and a redirectUrl. Hosted Entry then reads a safe summary:
GET /api/authorizations/summary?code=<short-lived-code>
The summary includes wallet address, a short-lived summary authorization code, spender, remaining budget, and app metadata. Before a product payment, exchange that summary code through POST /api/authorizations/payment-code; the returned payment code is bound to product/order/idempotency/amount/payTo and is consumed once by the payment route. The exchange and the product route call must come from the app origin that received the summary code; no-origin server calls are rejected. Backend-only purchases must use the trusted-server charge path with an app-scoped zpk_... key and charges:create. Summaries and payment codes must not include API keys, webhook secrets, admin credentials, relayer keys, or private user data.
16Two Payment Paths
16.1Direct Product Route
Use this for prototypes, simple games, mini apps, and paid actions that do not need a backend order first.
const idempotencyKey = "paid-action-10001";
const codeResponse = await fetch("https://api.zentpay.app/api/authorizations/payment-code", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
authorizationCode: "<summary-code>",
paymentUrl: "https://api.zentpay.app/api/pay/<app>/<product>",
idempotencyKey
})
});
const paymentCode = (await codeResponse.json()).authorization.authorizationCode;
await fetch("https://api.zentpay.app/api/pay/<app>/<product>", {
method: "POST",
headers: {
"content-type": "application/json",
"x-wallet-address": walletAddress,
"x-zentpay-budget-spender": budgetSpenderAddress
},
body: JSON.stringify({
authorizationCode: paymentCode,
developerUserId: "game-user-123",
idempotencyKey
})
});
This creates a charge, delivery, webhook delivery attempt, and settlement batch. It does not create an order unless you pass an existing orderId.
So this is normal:
Recent Orders: 0
Recent Deliveries: 5
Payment route failures return stable public code values. Handle those codes in your game UI; do not parse internal infrastructure or provider error text.
| Code | Meaning | Recommended UI |
|---|---|---|
BUDGET_REMAINING_INSUFFICIENT | The user has an active connection, but remaining ZentPay budget is lower than this purchase. | Open Portal with renew=1 and ask the user to renew budget. |
BUDGET_AUTHORIZATION_EXPIRED | The permit-backed budget expired. | Open Portal and request a fresh budget authorization. |
BUDGET_AUTHORIZATION_INVALID | The saved authorization no longer matches the wallet, spender, network, or active state. | Reconnect ZentPay for this account. |
AUTHORIZATION_CODE_REQUIRED | The route needs a product-bound payment authorizationCode; raw authorizationId and summary codes are not accepted. | Exchange the latest summary or account handoff code through /api/authorizations/payment-code. |
AUTHORIZATION_CODE_CONSUMED | The payment code was already used once. | Create a fresh payment code with a new idempotency key before retrying. |
AUTHORIZATION_CODE_INTENT_MISMATCH | The payment code does not match this product, order, amount, payTo, network, or idempotency key. | Rebuild the payment code for the exact purchase intent. |
ACCOUNT_CONNECTION_MISMATCH | The accountConnectionId does not match the signed authorization code. | Clear the local ZentPay account cache and create a new handoff. |
PAYMENT_LEDGER_UNAVAILABLE | ZentPay could not reach its payment ledger. | Keep the purchase pending or let the user retry; do not grant items yet. |
For insufficient budget, ZentPay intentionally returns a stable public error shape instead of raw infrastructure or provider errors:
{
"ok": false,
"success": false,
"fulfilled": false,
"code": "BUDGET_REMAINING_INSUFFICIENT",
"error": "Budget remaining is insufficient. Renew your ZentPay budget and try again."
}
16.2Trusted-Server Charge
Use this for production game backends that already know the logged-in game account and want the fastest low-value fulfillment path without moving the browser-origin payment-code exchange to the server.
For gemix-style in-game purchases after account binding, this is the recommended path: the game backend already knows the developer user, the active account connection, the product, and the idempotency key, so it can charge with /api/charges and verify the returned paymentProof before granting value.
The backend calls POST /api/charges with an app-scoped secret API key that has charges:create. ZentPay verifies the API key, runtime origin, product, optional order, account connection, active BudgetSpender authorization, amount, recipient, product risk policy, idempotency key, and payment proof policy in one atomic ledger reservation. The response includes paymentProof, a short-lived ZentPay-signed JWS envelope that your backend can verify before granting. For live products, trusted-server charge requires a product-level maxAmountAtomic; Dev Console and config-as-code default this limit to 1000000 atomic units, or 1 USDC.
import {
createTrustedServerCharge,
verifyZentPayPaymentProof
} from "@zentpay/x402-pay/server";
const charge = await createTrustedServerCharge({
apiKey: process.env.ZENTPAY_API_KEY!,
runtimeOrigin: "https://gemix.apps.zentpay.app",
productSlug: "extra-moves-5",
accountConnectionId: ready.accountConnectionId,
developerUserId: currentUser.id,
idempotencyKey: `gemix:${currentUser.id}:extra-moves-5:${requestId}`,
maxAmountAtomic: "30000"
});
const proof = await verifyZentPayPaymentProof({
paymentProof: charge.paymentProof!,
expected: {
appId: process.env.ZENTPAY_APP_ID!,
productId: charge.delivery?.productId || undefined,
deliveryId: charge.deliveryId,
idempotencyKey: charge.idempotencyKey,
accountConnectionId: ready.accountConnectionId,
developerUserId: currentUser.id,
amountAtomic: "30000",
payTo: process.env.ZENTPAY_PAY_TO!,
network: "eip155:84532"
}
});
await grantOnce({
deliveryId: proof.deliveryId,
idempotencyKey: proof.idempotencyKey,
developerUserId: proof.developerUserId,
sku: proof.sku
});
paymentProof is a fulfillment proof, not a secret and not a storage contract. Verify the JWS signature, typ, expiration, app/product/order/idempotency, amount, payTo, network, developerUserId, and accountConnectionId. The SDK pins the issuer to https://api.zentpay.app by default; only pass a custom expectedIssuer or jwksUrl for local/test APIs you control. Do not trust arbitrary proof tokens supplied by browser clients without pinning issuer, JWKS, and every expected intent field. Fulfillment must still be idempotent by deliveryId or idempotencyKey, because the signed webhook is sent asynchronously after ZentPay accepts the delivery and should become the final reconciliation record. For irreversible grants, store the proof jti/proofId or idempotencyKey in your own fulfillment ledger and reject repeats; signature verification alone does not replace a replay cache.
Trusted-server charges are disabled by default per product. Enable them only for riskMode=optimistic products whose value is low enough to compensate if later settlement fails. Higher-value or non-recoverable products should use settled_first and the normal order/product route flow.
npx zentpay product update \
--app gemix \
--product zprod_... \
--trusted-server-charge \
--trusted-server-max-amount "$0.03"
16.3Server-Created Order
Use this when your backend has users, carts, inventory, purchase history, or a business order id. For dynamic pricing, this is the required path: your backend calculates the amount, creates the ZentPay order with a secret zpk_... API key, and the payment-code exchange binds the final orderId, amountAtomic, payTo, network, and idempotencyKey into a one-time payment code.
Dynamic amounts must not be accepted directly from the browser into the product payment route. The browser can send cart or usage details to your own backend; your backend decides the amount and calls POST /api/orders.
Create a dynamic-priced product once:
npx zentpay product create \
--app gemix \
--name "AI Usage" \
--price "$0.01" \
--pricing dynamic_order \
--min-amount "$0.01" \
--max-amount "$5.00"
price remains the display/default catalog price. pricingMode: dynamic_order means actual charges for this product must be backed by a server-created order.
curl -X POST https://api.zentpay.app/api/orders \
-H "authorization: Bearer zpk_test_..." \
-H "content-type: application/json" \
-d '{
"productId": "zprod_...",
"developerUserId": "game-user-123",
"idempotencyKey": "order-10001",
"amountAtomic": "370000",
"metadata": {
"type": "usage_charge",
"units": 37,
"unitPriceAtomic": "10000"
}
}'
Response:
{
"order": {
"id": "zord_...",
"status": "created",
"amountAtomic": "370000"
},
"checkoutUrl": "https://api.zentpay.app/api/pay/<app>/<product>",
"paymentUrl": "https://api.zentpay.app/api/pay/<app>/<product>",
"status": "created",
"nextAction": "exchange_authorization_code",
"product": {
"sku": "gemix_continue",
"paymentUrl": "https://api.zentpay.app/api/pay/<app>/<product>"
}
}
The stable order response fields are order.id, checkoutUrl, product.paymentUrl, status, and nextAction.
Then exchange the latest summary code for the order-bound payment code and call the returned product route from the client or your app shell:
const idempotencyKey = "order-10001";
const codeResponse = await fetch("https://api.zentpay.app/api/authorizations/payment-code", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
authorizationCode: summary.authorizationCode,
paymentUrl,
orderId: "zord_...",
idempotencyKey
})
});
const paymentCode = (await codeResponse.json()).authorization.authorizationCode;
await fetch(paymentUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"x-wallet-address": walletAddress,
"x-zentpay-budget-spender": budgetSpenderAddress
},
body: JSON.stringify({
orderId: "zord_...",
authorizationCode: paymentCode,
developerUserId: "game-user-123",
idempotencyKey
})
});
When orderId is present, ZentPay uses the order's locked amountAtomic and idempotency key. Reusing the same order with a different amount or idempotency key is rejected; retrying the same idempotent request returns the existing payment result instead of creating a second charge.
Read order state from your backend:
curl https://api.zentpay.app/api/orders/zord_... \
-H "authorization: Bearer zpk_test_..."
16.4Task-Bound Purchase Vs Account Top-Up
Use metadata and idempotency keys to distinguish "unlock this task now" from "add account credits".
Task-bound purchase:
{
"productId": "zprod_review_unlock",
"developerUserId": "game-user-123",
"idempotencyKey": "review-task:task_123:unlock",
"metadata": {
"type": "review_task_unlock",
"reviewTaskId": "task_123"
}
}
Account top-up:
{
"productId": "zprod_credit_pack",
"developerUserId": "game-user-123",
"idempotencyKey": "account-topup:game-user-123:pack-100:uuid",
"metadata": {
"type": "account_top_up",
"credits": 100
}
}
In the webhook, branch on event.metadata.type. For task-bound purchases, grant entitlement to metadata.reviewTaskId. For top-ups, increment the account ledger without requiring a task id. Both flows must dedupe by delivery.deliveryId or delivery.idempotencyKey.
17API Keys
Create API keys in Dev Console and store the full secret immediately. ZentPay only shows the full secret once.
Authorization: Bearer zpk_test_...
or:
x-zentpay-secret-key: zpk_test_...
Default v1.2 scopes:
orders:createorders:readcharges:createreceipts:readdeliveries:readconnections:createconnections:read
charges:create is the trusted-server charge scope. It must stay on app-scoped backend keys and should be paired with product-level maxAmountAtomic limits.
API keys are app or organization scoped backend secrets. Do not ship them in frontend code.
17.1API Error Shape
Public developer API and control-plane failures use a structured error shape so CLI output, Console diagnostics, and backend logs can point to the real fix:
{
"code": "invalid_return_url",
"message": "returnUrl must point to the matching Portal or app runtime path.",
"field": "returnUrl",
"hint": "Use the matching Portal, runtime, localhost, or 127.0.0.1 return URL."
}
Common integration codes:
| Code | Where It Appears | Fix |
|---|---|---|
developer_auth_required | Dev Console API, Config Plan/Apply, CLI control-plane commands | Run zentpay auth browser or sign in to Dev Console. |
developer_cli_scope_missing | CLI grant exists but lacks a required control-plane scope | Re-run zentpay auth browser with the scope named by the CLI, such as keys:create. |
valid_api_key_required | Runtime backend APIs such as account handoff, orders, deliveries, and receipts | Send a server-side zpk_... key with Authorization: Bearer ... or x-zentpay-secret-key. |
api_key_scope_missing | Backend key is valid but lacks an operation scope | Create or rotate the backend key with the required scope, for example connections:create or orders:create. |
invalid_return_url | Account handoff creation | Use the matching Portal URL, app runtime URL, or local development URL whose first path segment matches the app slug. |
missing_merchant_user_id | Account handoff creation | Pass the stable developer-side game/app account id as merchantUserId. |
handoff_invalid_or_expired | Account handoff summary or confirm | Create a fresh handoff with a backend key that has connections:create. |
wallet_signature_required | Account connection list or unlink | Sign the account-connection proof message with the connected wallet owner. |
active_budget_required | Account connection confirm | Send the user through ZentPay authorization or renew flow before confirming the connection. |
invalid_budget_spender | Account connection confirm | Use the BudgetSpender address returned by ZentPay for the active network. |
account_connection_not_found | Account connection unlink | Refresh account status before retrying unlink. |
Treat field, requiredScopes, hint, and authCommand as optional diagnostic helpers. Do not parse plain English error text when code is present.
ZentPay enforces rate limits on developer auth, developer control, API key management, webhook management, and settlement operations server-side. API keys must carry explicit scopes; empty scope arrays are denied.
18Webhooks
Create one webhook endpoint per app. ZentPay sends payment.delivered after the charge and delivery are durably accepted. Webhook delivery is asynchronous; payment APIs do not send developer webhooks inline. Your backend should verify the HMAC signature before fulfillment.
Webhook signing secrets are shown only when they are created or rotated. If a secret is lost, rotate it and update the backend environment:
npx zentpay webhook rotate --webhook zwh_... --env .env.local
There is no safe "pull old secret" path. Dev Console and the CLI later display masked previews only. keys pull also writes masked guidance only; it does not recover API key or webhook signing secrets.
Webhook URLs must be HTTPS public endpoints. ZentPay rejects http://, localhost, private IP ranges, link-local addresses, cloud metadata endpoints such as 169.254.169.254, and internal hostnames such as .local or .internal. The Dev Console webhook delivery debugger redacts x-zentpay-signature and does not expose the signature base string; use your own backend logs if you need deeper request inspection during development.
x-zentpay-event: payment.delivered
x-zentpay-delivery-id: ztdlv_...
x-zentpay-idempotency-key: zentpay:...
x-zentpay-timestamp: 2026-05-09T00:00:00.000Z
x-zentpay-signature: sha256=...
Typical payload:
{
"id": "zevt_...",
"type": "payment.delivered",
"event": {
"id": "zevt_...",
"type": "payment.delivered",
"createdAt": "2026-05-09T00:00:00.000Z",
"test": false
},
"test": false,
"app": { "id": "zapp_...", "slug": "game", "payTo": "0x..." },
"product": { "id": "zprod_...", "slug": "zent", "sku": "game_zent", "priceAtomic": "10000" },
"order": { "id": "zord_...", "status": "delivered" },
"deliveryId": "ztdlv_...",
"merchantUserId": "game-user-123",
"metadata": { "type": "account_top_up", "credits": 100 },
"payment": { "mode": "budget-spender", "payTo": "0x...", "settlementStatus": "queued" },
"delivery": {
"deliveryId": "ztdlv_...",
"chargeId": "0x...",
"orderId": "zord_...",
"payTo": "0x...",
"settlementStatus": "queued",
"idempotencyKey": "zentpay:..."
}
}
Stable contract fields:
event.idand top-levelidare the webhook event id.event.typeand top-leveltypearepayment.delivered.order.idis present when the payment was linked to an order.delivery.deliveryIdand top-leveldeliveryIdare the fulfillment ids.merchantUserIdis the developer game/app account id.metadatais copied from the order or fulfillment metadata.product.skuis the configured developer SKU.
Verify the signature from the raw request body:
import { verifyZentPayWebhookSignature } from "@zentpay/x402-pay/server";
function verifyZentPayWebhook(rawBody: string, timestamp: string, signature: string, secret: string) {
return verifyZentPayWebhookSignature({
rawBody,
timestamp,
signature,
signingSecret: secret
});
}
Return 2xx only after successful fulfillment. If the same delivery arrives again, return 2xx but do not issue the reward twice. If your backend is temporarily unavailable, return 5xx and ZentPay will record the failed webhook delivery for retry.
Test webhook payloads use the same payment.delivered shape with test: true and a webhook-test idempotency key. They do not represent a real charge or delivery row. Treat them as signed health checks: verify the signature and return 2xx, but do not write business fulfillment rows, grant entitlements, or parse merchantUserId / developerUserId as a production user id. Dev Console test users use zentpay-webhook-test, which is intentionally not a UUID.
For CLI diagnostics, zentpay doctor is a static configuration check by default. Run zentpay doctor --deep --app <app-slug> or zentpay webhook test --webhook <zwh_...> to send a signed webhook probe to your endpoint.
Dev Console and zentpay webhook list --app <app-slug> --failed --json show recent webhook delivery attempts. If an endpoint is fixed after a failure, use the retry button on the webhook delivery row or run zentpay webhook retry --event <evt_...> --webhook <zwh_...> --yes --json. Retry results reuse the same event id and update response status, attempt count, and next retry time. Delivery rows show whether a request was signed, but signature headers are redacted.
The Dev Console Overview also includes a Webhook health summary for the selected app. It shows the latest delivered attempt, latest failed attempt, safe failure category, next retry or retry-exhausted state, and the location of the manual retry action. Categories include 404, 401, 5xx, timeout, route, proxy, missing secret, and unknown. The summary gives endpoint-focused fixes without exposing response bodies, signing secrets, signature base strings, internal table names, or private backend details.
18.1Delivery And Fulfillment Health
Dev Console can display Delivery / Fulfillment health for the selected app. This is an observability view for payment and developer-grant status; it does not change the fulfillment proof contract.
Health guidance uses developer-facing states:
payment recorded but grant pending: ZentPay has recorded the payment, but the developer backend has not yet surfaced a matching grant. Check Recent Deliveries, then run the app's Sync purchase or inventory sync path.server-confirmed grants: the developer backend reports that the entitlement, inventory, credit, download, pass, or usage grant was written through its idempotent fulfillment ledger.webhook delayed/retrying: the webhook endpoint has not completed yet or returned a retryable failure. Use Webhook Deliveries to inspect the safe failure category, fix the endpoint, then use manual retry.manual retry / sync / reconcile guidance: use webhook delivery retry for endpoint failures, the frontend Sync purchase action to refresh the developer inventory/status endpoint, and a backend reconcile route to verifypaymentProofor a trusted delivery lookup before writing any missing grant once.
Frontend success, return URLs, and postMessage completion events are sync hints only. They may tell the app to refresh account state, poll inventory, or resume a pending purchase, but they are not delivery evidence. Real fulfillment evidence remains a verified paymentProof, a signed payment.delivered webhook, or a trusted delivery lookup performed by the developer backend.
Developers do not need and should not query ZentPay's private database. Do not build integrations around ZentPay storage, internal table names, RPC names, private log names, or ZentPay-only diagnostics. Use public API responses, signed webhooks, Dev Console health summaries, and your own durable fulfillment ledger.
Use the SDK fixtures for local tests:
import {
createZentPayMockFetch,
createZentPayWebhookFixture,
zentPayHandoffNeedsBudgetFixture,
zentPayHandoffNeedsConnectionFixture,
zentPayHandoffReadyFixture
} from "@zentpay/x402-pay/testing";
const fixture = createZentPayWebhookFixture({
signingSecret: "zwhsec_test_secret"
});
const mockFetch = createZentPayMockFetch({
apiKey: "zpk_test_mock",
handoffStatus: "ready"
});
The payment API returns after the delivery is recorded; it does not wait for your webhook endpoint to finish. If Dev Console shows HTTP 522, Cloudflare could not reach the webhook origin before timing out. Check that the webhook URL is a real backend route, not an unconfigured runtime page, and that it returns 2xx only after idempotent fulfillment succeeds.
18.2Durable Fulfillment Storage
External apps should persist fulfillment in backend-owned durable storage chosen by the developer. ZentPay's public contract is the signed webhook, delivery lookup API, and payment proof fields documented here; do not design an integration around ZentPay internal storage or any particular storage provider.
At minimum, store a replay guard before granting value. Key that guard by deliveryId or idempotencyKey, and treat duplicate webhook deliveries as already handled. When test: true or the idempotency key starts with webhook-test:, return 2xx after signature verification but skip real fulfillment.
If you use the server SDK fulfillment helpers, implement one ZentPayFulfillmentStore adapter backed by your developer durable DB and pass that same store to verifyPaymentProofAndGrantOnce(), handleZentPayWebhook(), and reconcileDeliveryAndGrant(). Your inventory sync endpoint should read grants from that same store. Do not maintain separate ledgers for purchase, webhook, reconcile, and inventory sync paths, and do not use an in-memory Map as production fulfillment storage.
18.3Next.js Route Example
import { verifyZentPayWebhookRequest } from "@zentpay/x402-pay/server";
export async function POST(req: Request) {
let event;
try {
({ event } = await verifyZentPayWebhookRequest({
request: req,
signingSecret: process.env.ZENTPAY_WEBHOOK_SECRET!
}));
} catch {
return new Response("bad signature", { status: 401 });
}
const idempotencyKey = event.delivery.idempotencyKey || event.delivery.deliveryId;
if (event.test === true || event.event?.test === true || idempotencyKey.startsWith("webhook-test:")) {
return Response.json({ ok: true, test: true, skipped: true });
}
await fulfillOnce(event.delivery.deliveryId, idempotencyKey);
return Response.json({ ok: true });
}
18.4Express Route Example
import { verifyZentPayWebhookSignature } from "@zentpay/x402-pay/server";
app.post("/api/webhook/zentpay", express.raw({ type: "application/json" }), async (req, res) => {
const rawBody = req.body.toString("utf8");
const timestamp = req.header("x-zentpay-timestamp") || "";
const signature = req.header("x-zentpay-signature") || "";
if (!verifyZentPayWebhookSignature({
rawBody,
timestamp,
signature,
signingSecret: process.env.ZENTPAY_WEBHOOK_SECRET!
})) {
return res.sendStatus(401);
}
const event = JSON.parse(rawBody);
await fulfillOnce(event.delivery.deliveryId, event.delivery.idempotencyKey);
res.json({ ok: true });
});
19Delivery Tracking
Every successful or reserved payment creates a delivery.
deliveryIdis the primary fulfillment id.idempotencyKeyis the replay-safe grant key.orderIdis present only when the payment was linked to an order.payTois the app's Base USDC recipient.settlementStatusmay bequeued,settling,settled,failed,settlement_submission_pending, orsettlement_finalization_pending.
Query by delivery id:
curl https://api.zentpay.app/api/deliveries/ztdlv_... \
-H "authorization: Bearer zpk_test_..."
Or use the server SDK helper:
import { reconcileDelivery } from "@zentpay/x402-pay/server";
const delivery = await reconcileDelivery({
deliveryId: "ztdlv_...",
apiKey: process.env.ZENTPAY_API_KEY!
});
Query a receipt:
curl https://api.zentpay.app/api/receipts/0x... \
-H "authorization: Bearer zpk_test_..."
Delivery and receipt lookup endpoints require a developer session or backend secret API key. Do not design fulfillment around a public delivery id lookup.
Recent Deliveries in Dev Console is the fastest way to confirm that a direct product route produced a fulfillment record. Recent Orders only changes when your backend explicitly calls POST /api/orders; it is normal for deliveries to exist while orders are empty.
20Fulfillment Risk Modes
Products have a riskMode so developers can choose the right UX and accounting boundary:
optimistic: ZentPay records the charge and delivery, sendspayment.deliveredasynchronously, then settles on-chain asynchronously. Use this only for low-value, reversible, or easily compensatable actions.settled_first: ZentPay waits for on-chain settlement before treating the delivery as ready for irreversible fulfillment; webhook delivery is held until settlement succeeds. Use this for high-value, scarce, regulated, non-recoverable, or customer-support-sensitive products.
payment.delivered is the fulfillment signal within the selected product risk mode. If an optimistic charge later fails settlement, the developer backend must be able to revoke, compensate, or reconcile the entitlement.
For settled_first, a payment route may return a pending delivery with fulfilled: false while settlement is still in progress. Do not grant the item from that response. Wait for payment.delivered, verify a delivery lookup, or use the final settled response from a route that explicitly waits for settlement. This path is slower by design because ZentPay does not treat the delivery as fulfilled until settlement succeeds.
Risk mode is separate from the verification mechanism:
- Signed webhook: durable reconciliation signal for every production integration.
- Delivery lookup: backend pull-based verification with
deliveries:read. paymentProof: low-latency signed fulfillment proof returned by ZentPay for immediate backend grants.
For optimistic, paymentProof means the charge and delivery were durably accepted and settlement is queued. For settled_first, fulfillment waits until settlement succeeds, so the user-visible path is slower by design.
Trusted-server charge is intended for low-value optimistic products. A developer backend may verify the paymentProof immediately and grant before the webhook arrives, but it must keep its own replay cache keyed by deliveryId, idempotencyKey, or the proof jti. The signed webhook remains the durable reconciliation source.
21Settlement And Reconciliation
ZentPay can queue small charges for a smoother user experience, then settle a batch on-chain through BudgetSpenderV2:
queued -> settling -> settled
queued -> settling -> failed
queued -> settling -> settlement_submission_pending -> reconciled
queued -> settling -> settlement_finalization_pending -> reconciled
failed -> queued -> settling -> settled
ZentPay reconciles on-chain ChargeSettled events with settlement batches, charges, and deliveries. Developers should use Dev Console and API status fields to inspect queued, settling, settled, failed, or pending reconciliation states.
Developers should treat payment.delivered webhook delivery as the fulfillment signal and use Dev Console to inspect later settlement status.
Once ZentPay has submitted a settlement successfully on-chain, later status finalization delays are treated as a platform reconciliation issue rather than as a charge failure. The charge may remain pending finalization until ZentPay reconciles settlement status; developers should not revoke a grant solely because a later settlement-status refresh is temporarily missing.
If settlement submission or receipt confirmation times out, ZentPay treats the submission as ambiguous rather than failed. For settled_first, fulfillment stays pending until settlement status is finalized.
22Recipient Allowlist
BudgetSpenderV2 can expose a contract-level recipient allowlist check for settlement operations. This is separate from pay_to_status: the pay_to review only decides whether an app can receive ZentPay Portal backing and public listing, and must not be interpreted as granting or revoking the address's ability to receive payments.
Dev Console can check:
allowedRecipients(pay_to)
ZentPay may show this check in Dev Console for operator visibility. allowedRecipients(pay_to)=false means the technical check is not currently green; it must not automatically mark pay_to_status=blocked, delist the app, or replace an explicit pay_to review decision. Allowlist changes remain a ZentPay-managed operation and are not exposed to developer browsers or clients.
23Budget Management
Users can see remaining budget and spender in Portal. They can renew the authorization or revoke it without ETH by signing Permit(value=0). ZentPay relays that permit and marks the authorization as revoked in the ledger.
Revoke is required because the budget is global across connected apps. It is the user-facing kill switch for future ZentPay charges. Disconnect only ends the visible wallet session, and Unlink only removes one app/game account connection.
Runtime wallet widgets that expose Renew must use the hosted authorization URL with renew=1 or forceAuthorize=1. Without that flag, Portal may reuse a cached active BudgetSpender authorization and immediately return to the app, which is correct for normal launch but not for renewing a budget.
This preserves the no-gas model:
- User signs USDC Permit typed data.
- ZentPay relayer pays gas.
- User does not call
approve,charge, or allowlist functions.
24Platform Notes
Telegram Mini Apps, Farcaster Mini Apps, mobile apps, and browser games should call your backend for order creation and fulfillment. Keep API keys and webhook signing secrets on the backend only.
For AI coding agents, provide this document, your app slug, product slug, and webhook URL. The agent should implement product route calls, backend order creation if needed, raw-body webhook verification, and idempotent fulfillment.
25Security Checklist
- API keys stay on the backend.
- Webhook signing secrets stay on the backend.
- Fulfillment depends on signed webhook or trusted delivery lookup.
- Fulfillment is idempotent by
deliveryIdoridempotencyKey. - Frontend payment success is treated as UI feedback only.
pay_tois the developer's Base USDC address. pay_to review controls Portal backing and public listing only; it is not a custody or recipient permission switch.- The user Permit spender is BudgetSpenderV2.
recipient,appId,productId,deliveryId,zent, andchargeIdnever enter the user's Permit typed data.
26Go-Live Checklist
- App created in Dev Console.
- Base USDC receive address set and confirmed.
- Products created with expected prices.
- Signed webhook live test is complete:
Test Webhooksucceeds for HMAC, one real payment has produced a deliveredpayment.deliveredattempt, and the backend grants value only after signature verification. - Webhook endpoint is HTTPS and publicly reachable, not localhost, private IP, or a metadata/internal hostname.
- Backend key scopes match the chosen payment path and the key is stored server-side. For order, trusted charge, delivery, receipt, and account binding flows, use
orders:create,orders:read,charges:create,deliveries:read,receipts:read,connections:create,connections:read. - If using Worker Quick Launch, Runtime manifest and proxy path are verified:
/.well-known/zentpay.json,/api/zentpay/webhook, concreteappPath/entryPath, Runtime statusactive, and submitted domain request. - App-owned inventory sync endpoint is implemented, such as authenticated
POST /api/inventory/sync, and it refreshes state from the durable fulfillment ledger after return, retry, launch, and support recovery. - Durable fulfillment ledger exists in the developer backend and dedupes by
deliveryIdoridempotencyKey. Trusted-server charge grants verifypaymentProofbefore immediate grant and reconcile the signed webhook. - Direct product route tested.
- Server-created order flow tested if used.
- Delivery appears in Dev Console.
- Webhook retry behavior is understood.
- Settlement status appears as expected.
- Hosted Entry works at
https://portal.zentpay.app/<app-slug>if the app does not have its own domain. - If using
<app-slug>.apps.zentpay.app, ZentPay has approved the exact hostname and the certificate status is active.
27Troubleshooting
| Symptom | Likely Cause | What To Check |
|---|---|---|
| Deliveries exist but orders are empty | App used direct product route | Create orders from backend if you need order tracking |
| Webhook retries keep growing | Endpoint returns non-2xx or signature verification fails | Raw body handling, signing secret, response status |
| Test Webhook or live webhook returns 404 | Backend route is missing or the runtime proxy path is not active | Deploy POST /api/zentpay/webhook on the developer-owned backend origin, or use https://<app-slug>.apps.zentpay.app/api/zentpay/webhook only after Runtime status is active |
| Webhook URL is rejected | URL is not an HTTPS public endpoint | Deploy a public HTTPS backend route; do not use localhost, private IPs, or metadata/internal hosts |
| Payment route rejects the app | Missing or invalid pay_to | App receive address |
| User can authorize but payment fails | Insufficient USDC, expired authorization, or settlement failure | Portal budget summary, charge status, operation alert |
| Delivery lookup or trusted charge returns 401 or 403 | Missing backend API key or scope | Create or rotate a server-only Backend key with the required scope, such as deliveries:read, receipts:read, charges:create, or connections:create, then redeploy the backend secret |
| User paid but inventory does not refresh | App has no inventory sync endpoint or it is not reading the durable ledger | Implement authenticated POST /api/inventory/sync in the app backend and have it reconcile by deliveryId or idempotencyKey before updating client inventory |
| Hosted Entry returns to the wrong page | Invalid return URL | Return URL must match https://portal.zentpay.app/<app-slug> or https://<app-slug>.apps.zentpay.app/<appPath> |
| Runtime verification fails | Manifest does not match app id, slug, pay_to, webhook path, or origin | Check /.well-known/zentpay.json and Runtime origin in Dev Console, then run Save + Verify or Recheck Runtime |
| Runtime URL shows setup/inactive page | Runtime is not verified, disabled, or DNS/TLS is not approved | Run Save + Verify, then Request Domain and wait for exact hostname approval |
28No-Gas Invariants
- The user signs only USDC EIP-2612 Permit typed data.
- The permit spender is BudgetSpenderV2.
- Recipient, app id, product id, delivery id, zent, and charge id never enter the user's Permit typed data.
- Users never call
chargeor allowlist functions. - ZentPay relayer pays gas for permit and charge settlement.
- Fulfillment must depend on signed webhook or trusted server lookup, never frontend success alone.