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; raw authorizationId and 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 summary authorizationCode; 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.delivered is 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.

PathBest FitWho Creates The IntentBrowser StepServer Step
Hosted Entry / product routeFirst 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 handoffDynamic 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/chargesLogged-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.delivered webhook, a verified paymentProof, or a trusted delivery lookup performed by the developer backend with a server-only zpk_... key.
  • ZENTPAY_RUNTIME_ORIGIN is the developer-owned deployed origin. In production it must not be http://localhost, http://127.0.0.1:3000, or https://<app-slug>.apps.zentpay.app.
  • ZENTPAY_RETURN_URL for production account binding should be https://<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.json is public-ish control-plane config. One-time secrets (ZENTPAY_API_KEY, ZENTPAY_WEBHOOK_SECRET) must be deployed through server env/secret tooling. zentpay keys pull can 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.

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.

ModeStatusWhat ZentPay HostsWhat Developer Hosts
External static siteAvailable nowPayment API, Portal authorization, Dev Console, webhooksGame/app frontend and fulfillment backend
Hosted EntryAvailable nowhttps://portal.zentpay.app/<app-slug> official profile, authorization bridge, Portal return flowGame/app frontend if using an external URL, plus fulfillment backend
Worker Quick LaunchAvailable now, runtime domain approval requiredhttps://<app-slug>.apps.zentpay.app runtime router, manifest verification, and inactive/setup pageDeveloper 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> or http://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:

EnvironmentZENTPAY_RUNTIME_ORIGINZENTPAY_RETURN_URLAllowed Purpose
Local devhttp://127.0.0.1:<port> or http://localhost:<port>http://127.0.0.1:<port>/<app-slug>/... only for local account handoff testingLocal browser development only; never deploy these values.
Preview/testPublic HTTPS preview such as a Worker, Vercel, or tunnel URLMatching public HTTPS callback URL or the hosted callback for the test appShared QA and Dev Console verification.
ProductionDeveloper-owned public HTTPS origin, for example https://your-worker.workers.dev or a custom domainhttps://<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:

  1. Deploy the Worker to https://your-worker.workers.dev.
  2. Click Save + Verify in 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.
  3. Use Recheck Runtime later only if you need to re-run the technical probe.
  4. Click Request Domain after 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 paymentProof or perform trusted delivery lookup before immediate backend fulfillment when using trusted-server charges or recovery sync.
  • Receive signed payment.delivered webhooks for reconciliation.
  • Verify x-zentpay-signature from the raw request body.
  • Fulfill once through one shared idempotent ledger keyed by deliveryId and idempotencyKey.
  • 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.

TemplatePrice
zent0.01 USDC
Retry / Continue0.01 USDC
Unlock Level0.03 USDC
Generate Once0.05 USDC
Export / Download0.10 USDC
Tip Creatorcustom 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

  1. Register or log in at https://dev.zentpay.app with email/username and password. Registration requires email, username, password, and an email verification code; the resend button is rate-limited to 60 seconds.
  2. 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 with pay_to_status=unbound and can be completed later. Binding or changing pay_to starts ZentPay's review for Portal backing and public listing; it does not grant or revoke the address's ability to receive payments.
  3. Create one or more products. A product receives a hosted payment route shaped like /api/pay/<app-slug>/<product-slug>.
  4. Create a webhook endpoint for fulfillment.
  5. Use Test Webhook to confirm raw-body HMAC verification and idempotent fulfillment before real traffic.
  6. 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.
  7. 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.
  8. Run one real test payment before production traffic.
  9. 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 / paymentProof grant path.
  10. If using Worker Quick Launch, save and verify Runtime origin, then request the runtime domain.
  11. 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

FieldHow To Fill
App namePublic display name, for example Gemix. The app slug is generated from this name.
TypeGame for playable experiences; Application for tools, AI utilities, SaaS, marketplaces, or non-game apps.
HeadlineOne short public line shown on the official entry page.
Entry URLDeveloper-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 toOptional 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 targetsWhere users will open the app: Web, PWA, Telegram, Farcaster, or Base. This is metadata, not a hosting switch.
TagsComma-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.ts
  • app/api/zentpay/payment-submitted/route.ts when your app uses a dedicated callback recovery endpoint
  • app/api/zentpay/reconcile/route.ts
  • app/api/inventory/route.ts
  • app/api/inventory/sync/route.ts
  • app/.well-known/zentpay.json/route.ts
  • src/lib/zentpay.ts
  • tests/zentpay.webhook.test.ts
  • zentpay.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; copy zentpay.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 for zentpay config plan/apply.
  • zentpay.toml: the local project mapping. The CLI reads api_url, app, app_slug, entry_url, pay_to, webhook_url, products_file, and [runtime].origin automatically.
  • products.json: the legacy product catalog input for zentpay product upsert and old zentpay configure flows.
  • 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

FormRequired Information
ProductApp, product name, USDC price, template, risk mode, fulfillment adapter, and JSON fulfillment config.
WebhookApp and HTTPS POST endpoint. The endpoint must verify x-zentpay-signature from the raw request body before granting items or credits.
API keyScope, label, and environment. The full zpk_... secret is shown once and must stay on the backend.
RuntimeOptional 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

  1. Confirm the app row in Dev Console:
  • Status is active;
  • payToStatus is approved;
  • portalListingStatus is listed before using Portal discovery;
  • Runtime status is active if using https://gemix.apps.zentpay.app.
  1. Create or review products. Copy the product route shown in the Products table, for example:
https://api.zentpay.app/api/pay/gemix/<product-slug>
  1. 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
  1. Store the webhook signing secret returned by Dev Console as a server-side secret:
ZENTPAY_WEBHOOK_SECRET=zwhsec_...
  1. Click Test Webhook. The endpoint should return 2xx only after it verifies x-zentpay-signature and records an idempotent fulfillment by deliveryId or idempotencyKey.
  2. Run one live signed webhook test with a real payment before production traffic. Dev Console should show a delivered payment.delivered attempt in Webhook Deliveries and a matching delivery record. If the webhook probe or payment webhook returns 404, deploy a real POST /api/zentpay/webhook route on the developer-owned backend origin, or use the exact https://gemix.apps.zentpay.app/api/zentpay/webhook proxy path only after Runtime status is active.
  3. 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.

  1. 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 0 unless the backend called POST /api/orders;
  • the user's remaining authorization balance changed.
  1. Implement the app-owned inventory sync endpoint before go-live. A common shape is an authenticated POST /api/inventory/sync route 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.
  2. Confirm the durable fulfillment ledger and paymentProof grant path. Grant once by deliveryId or idempotencyKey; for trusted-server charges, verify paymentProof before immediate grant, then reconcile the signed payment.delivered webhook.

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: use authorization.authorizationCode, ownerWallet, spender, and accountConnectionId to create a product-bound code at /api/authorizations/payment-code for the next product route call.
  • needs_connection: open handoff.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. Open handoff.portalUrl to 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 stateMeaningCard statusPrimary buttonTop pillPill color
checkingHandoff or summary is loading.Checking paymentCheckingZentPay · CheckingMuted gray.
game_login_neededThe developer game account is not logged in.Game login neededLog inZentPay · Log inNeutral gray.
needs_connectionThis game account has not enabled ZentPay payment.Payment offEnable paymentZentPay · Enable paymentTeal primary.
opening_portalPortal handoff is opening.Opening PortalOpeningZentPay · OpeningTeal busy.
readyConnection exists and global budget has remaining balance.Payment readyManageZentPay · 4.55 USDCSuccess green / teal.
needs_budgetConnection exists, but budget is missing or low.Budget lowRenew budgetZentPay · Renew budgetAmber.
budget_expiredThe permit-backed budget expired.Budget expiredRenew budgetZentPay · Renew budgetAmber.
budget_revokedThe user revoked the ZentPay budget.Budget revokedRenew budgetZentPay · Renew budgetAmber.
sync_failedThe app could not refresh handoff or budget summary.Sync failedTry againZentPay · Try againRed.
unavailableZentPay API or payment ledger is unavailable.UnavailableTry againZentPay · UnavailableMuted 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 payment and Opening.
  • Success green / teal for Payment ready and the balance pill.
  • Amber for Renew budget, Budget low, Budget expired, and Budget revoked.
  • Red for Sync failed / Try again.
  • Muted gray for Checking, Log in, and Unavailable.

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-123 for multiple real users. A shared merchantUserId represents one developer-side account and can make different browser profiles intentionally resolve to the same game account.
  • emailHint is 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 ready one-click path safely, include a stable developer-side identity hint such as emailHint for logged-in accounts, or walletHint when your app already knows the intended wallet. ZentPay does not auto-return a previous wallet connection for a bare/shared merchantUserId handoff.
  • 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 wallet and 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 signing Permit(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 USDC when the handoff is ready.
  • ZentPay · Enable payment when the game account still needs a connection.
  • ZentPay · Renew budget when 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 example Logged in and GemixPlayer#1234.
  • ZentPay: payment state, for example Payment ready, Payment off, or Budget low.
  • Payment wallet: shortened address with a Copy action, for example 0xaaf7...297d.
  • USDC balance: the user's current USDC balance.
  • Budget remaining: the ZentPay authorization ledger remaining amount.
  • Renew budget: opens Portal authorization with renew=1 or forceAuthorize=1.
  • Sync: refreshes wallet balance and the ZentPay authorization summary.
  • Manage: opens https://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 PatternCompletion SignalRecovery Rule
In-page iframe modalCallback 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 windowCallback 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 windowCallback 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:

  1. If there is no active authorization summary, open Portal in a modal iframe. mode=popup&embed=1 selects Portal's compact embedded layout; it is not a browser window.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
  1. When Portal redirects the iframe to /zentpay-auth-return?code=..., the standard lightweight callback page is served by ZentPay on https://<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, sends postMessage events 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>
  1. Store the returned summary authorizationCode, ownerWallet, spender, and remaining in runtime-origin storage and notify the game page with postMessage. In the account binding flow, the summary may also include accountConnectionId.
  2. 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.

CodeMeaningRecommended UI
BUDGET_REMAINING_INSUFFICIENTThe 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_EXPIREDThe permit-backed budget expired.Open Portal and request a fresh budget authorization.
BUDGET_AUTHORIZATION_INVALIDThe saved authorization no longer matches the wallet, spender, network, or active state.Reconnect ZentPay for this account.
AUTHORIZATION_CODE_REQUIREDThe 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_CONSUMEDThe payment code was already used once.Create a fresh payment code with a new idempotency key before retrying.
AUTHORIZATION_CODE_INTENT_MISMATCHThe 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_MISMATCHThe accountConnectionId does not match the signed authorization code.Clear the local ZentPay account cache and create a new handoff.
PAYMENT_LEDGER_UNAVAILABLEZentPay 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:create
  • orders:read
  • charges:create
  • receipts:read
  • deliveries:read
  • connections:create
  • connections: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:

CodeWhere It AppearsFix
developer_auth_requiredDev Console API, Config Plan/Apply, CLI control-plane commandsRun zentpay auth browser or sign in to Dev Console.
developer_cli_scope_missingCLI grant exists but lacks a required control-plane scopeRe-run zentpay auth browser with the scope named by the CLI, such as keys:create.
valid_api_key_requiredRuntime backend APIs such as account handoff, orders, deliveries, and receiptsSend a server-side zpk_... key with Authorization: Bearer ... or x-zentpay-secret-key.
api_key_scope_missingBackend key is valid but lacks an operation scopeCreate or rotate the backend key with the required scope, for example connections:create or orders:create.
invalid_return_urlAccount handoff creationUse the matching Portal URL, app runtime URL, or local development URL whose first path segment matches the app slug.
missing_merchant_user_idAccount handoff creationPass the stable developer-side game/app account id as merchantUserId.
handoff_invalid_or_expiredAccount handoff summary or confirmCreate a fresh handoff with a backend key that has connections:create.
wallet_signature_requiredAccount connection list or unlinkSign the account-connection proof message with the connected wallet owner.
active_budget_requiredAccount connection confirmSend the user through ZentPay authorization or renew flow before confirming the connection.
invalid_budget_spenderAccount connection confirmUse the BudgetSpender address returned by ZentPay for the active network.
account_connection_not_foundAccount connection unlinkRefresh 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.id and top-level id are the webhook event id.
  • event.type and top-level type are payment.delivered.
  • order.id is present when the payment was linked to an order.
  • delivery.deliveryId and top-level deliveryId are the fulfillment ids.
  • merchantUserId is the developer game/app account id.
  • metadata is copied from the order or fulfillment metadata.
  • product.sku is 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 verify paymentProof or 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.

  • deliveryId is the primary fulfillment id.
  • idempotencyKey is the replay-safe grant key.
  • orderId is present only when the payment was linked to an order.
  • payTo is the app's Base USDC recipient.
  • settlementStatus may be queued, settling, settled, failed, settlement_submission_pending, or settlement_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, sends payment.delivered asynchronously, 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 deliveryId or idempotencyKey.
  • Frontend payment success is treated as UI feedback only.
  • pay_to is 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, and chargeId never 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 Webhook succeeds for HMAC, one real payment has produced a delivered payment.delivered attempt, 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, concrete appPath / entryPath, Runtime status active, 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 deliveryId or idempotencyKey. Trusted-server charge grants verify paymentProof before 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

SymptomLikely CauseWhat To Check
Deliveries exist but orders are emptyApp used direct product routeCreate orders from backend if you need order tracking
Webhook retries keep growingEndpoint returns non-2xx or signature verification failsRaw body handling, signing secret, response status
Test Webhook or live webhook returns 404Backend route is missing or the runtime proxy path is not activeDeploy 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 rejectedURL is not an HTTPS public endpointDeploy a public HTTPS backend route; do not use localhost, private IPs, or metadata/internal hosts
Payment route rejects the appMissing or invalid pay_toApp receive address
User can authorize but payment failsInsufficient USDC, expired authorization, or settlement failurePortal budget summary, charge status, operation alert
Delivery lookup or trusted charge returns 401 or 403Missing backend API key or scopeCreate 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 refreshApp has no inventory sync endpoint or it is not reading the durable ledgerImplement 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 pageInvalid return URLReturn URL must match https://portal.zentpay.app/<app-slug> or https://<app-slug>.apps.zentpay.app/<appPath>
Runtime verification failsManifest does not match app id, slug, pay_to, webhook path, or originCheck /.well-known/zentpay.json and Runtime origin in Dev Console, then run Save + Verify or Recheck Runtime
Runtime URL shows setup/inactive pageRuntime is not verified, disabled, or DNS/TLS is not approvedRun 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 charge or 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.