<!-- Generated from docs/developer/integration.md. Do not edit by hand. -->

# 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-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: `@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/<app-slug>` official profile, authorization bridge, Portal return flow | Game/app frontend if using an external URL, plus fulfillment backend |
| Worker Quick Launch | Available now, manual DNS/TLS approval | `https://<app-slug>.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://<app-slug>.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/<app-slug>`
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 `<app-slug>.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/<app-slug> -> https://portal.zentpay.app/<app-slug>
https://launch.zentpay.app/<app-slug> -> https://portal.zentpay.app/<app-slug>
```

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
`<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.

## 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://<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:

```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 <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.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/<app-slug>` 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/<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. 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/<app-slug>` 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://<app-slug>.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 `<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:

```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/<product-slug>
```

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://<developer-worker>.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://<developer-worker>.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
<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:

```html
<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>
```

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=<short-lived-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/<product-slug>", {
  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/<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:

```text
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 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=<short-lived-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
`<app-slug>.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/<app>/<product>", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-wallet-address": walletAddress,
    "x-zentpay-budget-spender": budgetSpenderAddress
  },
  body: JSON.stringify({
    authorizationCode: "<short-lived-code>",
    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/<app>/<product>"
  }
}
```

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: "<short-lived-code>",
    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/<app-slug>` 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 `<app-slug>.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/<app-slug>` or `https://<app-slug>.apps.zentpay.app/<appPath>` |
| Runtime verification fails | Manifest does not match app id, slug, pay_to, webhook path, or origin | Check `/.well-known/zentpay.json` and Runtime origin in Dev Console |
| 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.
