# ZentPay Developer Integration Guide ZentPay lets games, mini apps, AI tools, and web apps charge from 1 zent. ```text 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. ## Current Status - Base Sepolia is the active network for v1.2. - Active BudgetSpenderV2 is `0xb524F0b6b5e35d8b4c24455fcab0390c20Be0324`. - Product routes require short-lived `authorizationCode`; raw `authorizationId` is for history, management, receipts, and debug only. - `payment.delivered` is the fulfillment event. `payment.settled` is the later chain settlement event. - External static site, Hosted Entry, and Worker Quick Launch are available now. Full static zip hosting, Base Mainnet launch, funding/onramp, and mobile companion are roadmap items. ## Production 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 Runtime: https://.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: `@zentpay/x402-pay@0.2.4` The server SDK helper examples below require `@zentpay/x402-pay` 0.2.4 or newer: ```sh npm install @zentpay/x402-pay@^0.2.4 ``` ## Hosting Modes ZentPay supports payment, official app entry, and Worker Quick Launch runtime flows today. Full static zip hosting is a v1.3 roadmap item and is not part of the current Base Sepolia v1.2 release. | Mode | Status | What ZentPay Hosts | What Developer Hosts | | --- | --- | --- | --- | | External static site | Available now | Payment API, Portal authorization, Dev Console, webhooks | Game/app frontend and fulfillment backend | | Hosted Entry | Available now | `https://portal.zentpay.app/` official profile, authorization bridge, Portal return flow | Game/app frontend if using an external URL, plus fulfillment backend | | Worker Quick Launch | Available now, manual DNS/TLS approval | `https://.apps.zentpay.app` runtime router, manifest verification, inactive/setup page, domain request notifications | Developer Cloudflare Worker with static frontend and fulfillment backend | | Static zip hosting | v1.3 after Base mainnet is stable | `https://.apps.zentpay.app` reviewed static build files, CDN routing, deployment versions | Fulfillment backend unless ZentPay later provides managed entitlements | | Mobile companion | Future | Wallet, budget, receipts, authorized apps | Third-party app experiences remain web-first | ZentPay keeps the public trust surface simple: `portal.zentpay.app/` is the official app entry, authorization entry, return URL, and fallback page. `launch.zentpay.app` is no longer the canonical docs path; it can remain as a legacy alias during migration. Use `.apps.zentpay.app` only for the actual developer app runtime when using Worker Quick Launch or future static hosting. Legacy URLs are kept as redirects during migration: ```text https://portal.zentpay.app/app/ -> https://portal.zentpay.app/ https://launch.zentpay.app/ -> https://portal.zentpay.app/ ``` Reserved app slugs cannot be registered because they belong to ZentPay system routes: ```text dev, docs, api, app, apps, authorize, health, statusz, supported, verify, settle, assets, admin, login, settings ``` Third-party app code should run on your own domain or on `.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. ## Worker Quick Launch Worker Quick Launch is the recommended bridge before full static zip hosting. The developer deploys a Cloudflare Worker. ZentPay verifies the Worker manifest, then can route the reviewed runtime hostname: ```text https://.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: ```text GET / GET /.well-known/zentpay.json POST /api/zentpay/webhook GET /api/inventory ``` `POST /api/zentpay/order` is optional. Add it only if your Worker backend wants to create ZentPay orders server-side with a secret API key before calling a product payment route. Worker secrets: ```text ZENTPAY_APP_ID= ZENTPAY_APP_SLUG= ZENTPAY_PAY_TO= ZENTPAY_RUNTIME_ORIGIN= ZENTPAY_WEBHOOK_SECRET= ZENTPAY_API_KEY= optional, only for server-created orders DATABASE_URL= optional, for durable inventory / entitlements ``` Runtime verification checks: ```text GET /.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.runtimeOrigin, when present, == the origin saved in Dev Console ``` Runtime status values: ```text not_configured -> pending_verification -> active active -> disabled ``` Dev Console runtime flow: 1. Deploy the Worker to `https://your-worker.workers.dev`. 2. Save that origin in the app's Runtime section. 3. Click `Verify Runtime`. If the manifest matches, the app runtime status becomes `active`. 4. Click `Request Domain` after verification. ZentPay records `runtimeDomainRequestStatus=pending_manual_dns` and sends the internal `runtime_domain_requested` notification for manual Cloudflare DNS/TLS approval. The runtime URL is the app surface. `portal.zentpay.app/` remains the official profile and authorization entry that can link to the runtime URL. ## Demo Vs Real-Money Apps A pure static game can go online 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. Use any backend stack you like: - Vercel Functions. - Next.js API routes. - Cloudflare Workers. - Supabase Edge Functions. - Your own Node, Go, Python, or game backend. Minimum real-money backend responsibilities: - Create a ZentPay order with a secret API key if you need order tracking, or let the frontend use a Direct Product Route for simple paid actions. - Receive `payment.delivered` webhooks for fulfillment. Treat `payment.settled` as the later settlement event. - Verify `x-zentpay-signature` from the raw request body. - Fulfill once by `deliveryId` or `idempotencyKey`. - Persist coins, inventory, passes, credits, exports, or downloads in your own database. - 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. ## Core 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. ## Default Product Templates Keep product names clear and product-like. The code and API should talk about `zent`, paid actions, products, orders, and deliveries. | Template | Price | | --- | --- | | zent | 0.01 USDC | | Retry / Continue | 0.01 USDC | | Unlock Level | 0.03 USDC | | Generate Once | 0.05 USDC | | Export / Download | 0.10 USDC | | Tip Creator | custom amount, later | ## Dev 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 and set its Base USDC receive address. This is `pay_to` and is required. Creating an app sends ZentPay ops a `developer_app_requested` notification for review. 3. Create one or more products. A product receives a hosted payment route shaped like `/api/pay//`. 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. Check recipient allowlist status before production traffic. 8. If using Worker Quick Launch, configure Runtime origin, verify the manifest, then request the runtime domain. 9. 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, service-role keys, relayer keys, or recipient allowlist owner keys in browser, game client, mobile, Telegram, or Farcaster frontend code. ### Create App Fields | Field | How To Fill | | --- | --- | | App name | Public display name, for example `Gemix`. The app slug is generated from this name. | | Type | `Game` for playable experiences; `Application` for tools, AI utilities, SaaS, marketplaces, or non-game apps. | | Headline | One short public line shown on the official entry page. | | Entry URL | Developer-owned public URL, for example `https://game.example.com/play`. If the developer has no domain yet, leave it blank and use `https://portal.zentpay.app/` or Worker Runtime later. | | Pay to | Base USDC recipient wallet. This is where developer revenue is sent. ZentPay ops must allowlist it before live settlement. | | Distribution targets | Where users will open the app: Web, PWA, Telegram, Farcaster, or Base. This is review metadata, not a hosting switch. | | Tags | Comma-separated discovery/review 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://.apps.zentpay.app`. ### Other Create Forms | Form | Required Information | | --- | --- | | Product | App, product name, USDC price, template, risk mode, fulfillment adapter, and JSON fulfillment config. | | Webhook | App and HTTPS POST endpoint. The endpoint must verify `x-zentpay-signature` from the raw request body before granting items or credits. | | API key | Scope, label, and environment. The full `zpk_...` secret is shown once and must stay on the backend. | | Runtime | Worker origin such as `https://your-worker.workers.dev`; then `Verify Runtime`; then `Request Domain` for manual DNS/TLS approval. | ## After App Approval After ZentPay ops approves the app recipient and runtime domain, the developer still has to finish their own runtime, webhook, product, and testing setup. 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 `` 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: ```text 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`. ### Developer Checklist 1. Confirm the app row in Dev Console: - `Status` is `active`; - `Allowlist` is `allowed`; - `Runtime status` is `active` if using `https://gemix.apps.zentpay.app`. 2. Create or review products. Copy the product route shown in the Products table, for example: ```text https://api.zentpay.app/api/pay/gemix/ ``` 3. Create a webhook endpoint. For Worker Quick Launch, use: ```text https://gemix.apps.zentpay.app/api/zentpay/webhook ``` During early testing, before the runtime hostname is active, you can use the developer Worker origin instead: ```text https://.workers.dev/api/zentpay/webhook ``` 4. Store the webhook signing secret returned by Dev Console as a server-side secret: ```text ZENTPAY_WEBHOOK_SECRET=zwhsec_... ``` 5. Click `Test Webhook`. The endpoint should return 2xx only after it verifies `x-zentpay-signature` and records an idempotent fulfillment by `deliveryId` or `idempotencyKey`. 6. Create an API key only if the backend will create orders or query private delivery/receipt state. Store it server-side: ```text ZENTPAY_API_KEY=zpk_test_... ``` 7. 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. ### Worker Quick Launch Environment For the Worker starter, set these values in the developer Worker, not in frontend code: ```toml ZENTPAY_APP_ID = "zapp_..." ZENTPAY_APP_SLUG = "gemix" ZENTPAY_PAY_TO = "0x..." ZENTPAY_RUNTIME_ORIGIN = "https://.workers.dev" ZENTPAY_WEBHOOK_SECRET = "zwhsec_..." ZENTPAY_API_KEY = "zpk_test_..." # optional, backend-only order 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. ### Purchase Integration Choices For the first launch, the simplest path is to send users to the Official Entry: ```html Buy with ZentPay ``` 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: ```html ``` After ZentPay is connected, apps should keep a small wallet surface visible in the app UI. The recommended pattern is a collapsed pill such as `ZentPay 4.55 USDC` that expands into a wallet card and can be collapsed again. The expanded card should include: - `Wallet`: shortened address, 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. - `Portal`: opens Portal or the app's hosted entry. - `Forget`: clears only the app's local authorization summary/session cache. 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. 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: ```text 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: ```text 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 ``` 2. When Portal redirects the iframe to `/zentpay-auth-return?code=...`, the lightweight callback page `postMessage`s the URL to the parent game and the parent exchanges the code: ```http GET https://api.zentpay.app/api/authorizations/summary?code= ``` 3. Store the returned `authorizationCode`, `ownerWallet`, `spender`, and `remaining` in runtime-origin storage and notify the game page with `postMessage`. 4. On purchase, call the copied product route: ```ts await fetch("https://api.zentpay.app/api/pay/gemix/", { method: "POST", headers: { "content-type": "application/json", "x-wallet-address": summary.ownerWallet, "x-zentpay-budget-spender": summary.spender }, body: JSON.stringify({ authorizationCode: summary.authorizationCode, developerUserId: "game-user-123", idempotencyKey: `gemix:${productSlug}:${crypto.randomUUID()}`, 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. ## Hosted Entry If a developer does not have a domain, ZentPay can expose the app at: ```text https://portal.zentpay.app/ ``` 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: ```text https://portal.zentpay.app/authorize?app=&return=https%3A%2F%2Fportal.zentpay.app%2F ``` After the user completes the Permit-backed budget authorization, Portal calls: ```http POST /api/authorizations/return-code ``` The API returns a short-lived signed `code` and a `redirectUrl`. Hosted Entry then reads a safe summary: ```http GET /api/authorizations/summary?code= ``` The summary includes wallet address, short-lived authorization code, spender, remaining budget, and app metadata. It must not include API keys, webhook secrets, service-role keys, relayer keys, or private user data. ## Hosted Apps Review Full zip hosting is a v1.3 direction after Base mainnet is stable. Once ZentPay hosts developer-uploaded H5 games, AI tools, PWA builds, or mini apps, ZentPay becomes responsible for third-party content, distribution, and monetization risk. The first version should be curated `ZentPay Hosted Apps`, not an open app store: - Allowlisted developers first. - Manual review before public release. - Low-risk categories first: casual H5 games, non-UGC AI tools, PWA utilities, and non-financial tools. - App-level kill switch for hosting, listing, payment route, and webhook usage. - Abuse reports and developer identity records for every hosted app. Reject by default: - Adult content or sexualized minors. - Gambling, betting, lottery, cash prizes, or redeemable-value rewards. - Phishing, scams, malware, misleading payments, or wallet-draining patterns. - Piracy, IP infringement, illegal goods, hate, or extreme violence. - Unlicensed financial services, securities, exchanges, leverage, or yield claims. Require extra manual review: - UGC, anonymous chat, or open comments. - AI-generated images, text, voice, or video. - Random rewards, loot boxes, contests, rankings, raffles, or creator tipping. - Apps targeted to children or likely to attract minors. Third-party hosted apps must not share sensitive origin state with Portal. Worker Quick Launch and full zip hosting should prefer `.apps.zentpay.app`, sandboxed iframes, or equivalent origin isolation. Runtime proxy strips request cookies and upstream `Set-Cookie` headers. Hosted app code must never receive API keys, webhook secrets, service-role keys, relayer keys, or allowlist owner keys. ## Mobile App Store Strategy The first Apple App Store / Google Play release should be a companion app, not a third-party game hall. Recommended positioning: ```text ZentPay Companion / Portal ``` Allowed first-version features: - User login. - Wallet and USDC funding status. - Budget authorization management. - Revoke and renew. - Receipts and payment history. - Authorized app list. - Security notices and support. - Lightweight developer console viewing. Avoid in the first mobile release: - Embedded third-party hosted app directory with in-app zent spending. - USDC payments inside the iOS or Android app that unlock in-app digital goods, game levels, game currency, or app functionality. - Gambling, betting, cash prizes, lottery, or redeemable-value rewards. - Child-directed third-party ads, tracking, or high-risk data collection. Store review notes should explain that ZentPay is not a gambling app, is not a cryptocurrency exchange, does not custody developer funds, lets users revoke authorization, and sends developer funds directly to the developer's Base USDC address. ## Two Payment Paths ### Direct Product Route Use this for prototypes, simple games, mini apps, and paid actions that do not need a backend order first. ```ts await fetch("https://api.zentpay.app/api/pay//", { method: "POST", headers: { "content-type": "application/json", "x-wallet-address": walletAddress, "x-zentpay-budget-spender": budgetSpenderAddress }, body: JSON.stringify({ authorizationCode: "", developerUserId: "game-user-123", idempotencyKey: "paid-action-10001" }) }); ``` 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: ```text Recent Orders: 0 Recent Deliveries: 5 ``` ### Server-Created Order Use this when your backend has users, carts, inventory, purchase history, or a business order id. ```bash 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" }' ``` Response: ```json { "order": { "id": "zord_...", "status": "created", "amountAtomic": "10000" }, "product": { "paymentUrl": "https://api.zentpay.app/api/pay//" } } ``` Then call the returned product route from the client or your app shell: ```ts 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: "", developerUserId: "game-user-123", idempotencyKey: "order-10001" }) }); ``` Read order state from your backend: ```bash curl https://api.zentpay.app/api/orders/zord_... \ -H "authorization: Bearer zpk_test_..." ``` ## API Keys Create API keys in Dev Console and store the full secret immediately. ZentPay only shows the full secret once. ```http Authorization: Bearer zpk_test_... ``` or: ```http x-zentpay-secret-key: zpk_test_... ``` Default v1.2 scopes: - `orders:create` - `orders:read` - `receipts:read` - `deliveries:read` API keys are app or organization scoped backend secrets. Do not ship them in frontend code. ZentPay rate-limits developer auth, API key management, webhook management, and settlement operations server-side. Control-plane audit events are an internal ZentPay security control and do not change your integration contract. Legacy API keys with empty scope arrays may keep working during migration, but new integrations should use explicit scopes because ZentPay can switch empty scopes to deny-by-default. ## Webhooks Create one webhook endpoint per app. ZentPay sends `payment.delivered` after the charge and delivery are durably recorded, and it may send `payment.settled` later when chain settlement is confirmed. Your backend should verify the HMAC signature before fulfillment. 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. ```http 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: ```json { "id": "zevt_...", "type": "payment.delivered", "test": false, "app": { "id": "zapp_...", "slug": "game", "payTo": "0x..." }, "product": { "id": "zprod_...", "slug": "zent", "priceAtomic": "10000" }, "payment": { "mode": "budget-spender", "payTo": "0x...", "settlementStatus": "queued" }, "delivery": { "deliveryId": "ztdlv_...", "chargeId": "0x...", "orderId": "zord_...", "payTo": "0x...", "settlementStatus": "queued", "idempotencyKey": "zentpay:..." } } ``` Verify the signature from the raw request body: ```ts 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. Dev Console shows recent webhook delivery attempts. If an endpoint is fixed after a failure, use the retry button on the webhook delivery row. 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 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. ### Next.js Route Example ```ts 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 }); } await fulfillOnce(event.delivery.deliveryId, event.delivery.idempotencyKey); return Response.json({ ok: true }); } ``` ### Express Route Example ```ts 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 }); }); ``` ## Delivery 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`, or `failed`. Query by delivery id: ```bash curl https://api.zentpay.app/api/deliveries/ztdlv_... \ -H "authorization: Bearer zpk_test_..." ``` Or use the server SDK helper: ```ts import { reconcileDelivery } from "@zentpay/x402-pay/server"; const delivery = await reconcileDelivery({ deliveryId: "ztdlv_...", apiKey: process.env.ZENTPAY_API_KEY! }); ``` Query a receipt: ```bash 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. ## Fulfillment 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`, 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. Use this for high-value, scarce, regulated, non-recoverable, or customer-support-sensitive products. `payment.delivered` is the fulfillment signal only within the selected product risk mode. `payment.settled` is the later chain settlement event. If an optimistic charge later fails settlement, the developer backend must be able to revoke, compensate, or reconcile the entitlement. ## Settlement And Reconciliation ZentPay can queue small charges for a smoother user experience, then settle a batch on-chain through BudgetSpenderV2: ```text queued -> settling -> settled queued -> settling -> failed failed -> queued -> settling -> settled ``` The reconciliation job compares on-chain `ChargeSettled` events with Supabase settlement batches, charges, and deliveries. Safe status drift can be repaired by the internal operator API. Amount, recipient, payer, token, or missing event problems alert ZentPay operations. Developers should treat `payment.delivered` webhook delivery as the fulfillment signal and use Dev Console to inspect later settlement status. ## Recipient Allowlist BudgetSpenderV2 only charges recipients that are on the contract allowlist. This protects users and keeps `pay_to` routing explicit. Dev Console can call the backend to check: ```text allowedRecipients(pay_to) ``` The backend syncs `recipient_allowlist_status`. Owner transactions remain a ZentPay operation and are not exposed to developer browsers or clients. ## Budget 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. 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. ## Platform 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. ## Security 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 and is allowlisted before production traffic. - The user Permit spender is BudgetSpenderV2. - `recipient`, `appId`, `productId`, `deliveryId`, `zent`, and `chargeId` never enter the user's Permit typed data. ## Go-Live Checklist - App created in Dev Console. - Base USDC receive address set and confirmed. - Recipient allowlist status is active. - Products created with expected prices. - Webhook endpoint created and test webhook succeeds. - Webhook endpoint is HTTPS and publicly reachable, not localhost, private IP, or a metadata/internal hostname. - API key created and stored server-side if using orders or lookup APIs. - 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/` if the app does not have its own domain. - If using Worker Quick Launch, runtime manifest verification passes, status is `active`, and the manual domain request has been submitted from Dev Console. - If using `.apps.zentpay.app`, ZentPay ops has approved the exact hostname and Cloudflare certificate status is active. ## Troubleshooting | Symptom | Likely Cause | What To Check | | --- | --- | --- | | Deliveries exist but orders are empty | App used direct product route | Create orders from backend if you need order tracking | | Webhook retries keep growing | Endpoint returns non-2xx or signature verification fails | Raw body handling, signing secret, response status | | Webhook URL is rejected | URL is not an HTTPS public endpoint | Deploy a public HTTPS backend route; do not use localhost, private IPs, or metadata/internal hosts | | Payment route rejects the app | Missing or invalid `pay_to` | App receive address and recipient allowlist status | | User can authorize but payment fails | Insufficient USDC, expired authorization, or settlement failure | Portal budget summary, charge status, operation alert | | Delivery lookup returns 401 or 403 | Missing API key or scope | Use backend API key with `deliveries:read` or `receipts:read` | | Hosted Entry returns to the wrong page | Invalid return URL | Return URL must match `https://portal.zentpay.app/` or `https://.apps.zentpay.app/` | | Runtime verification fails | Manifest does not match app id, slug, pay_to, webhook path, or origin | Check `/.well-known/zentpay.json` and Runtime origin in Dev Console | | Runtime URL shows setup/inactive page | Runtime is not verified, disabled, or DNS/TLS is not approved | Verify Runtime, then Request Domain and wait for exact hostname approval | ## No-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.