> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zennopay.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Quickstart

> Stand up a sandbox integration end-to-end in about 10 minutes — HMAC only, no key pair.

This walks through the minimum a partner backend needs to do to create a
payment intent and hand it off to the Zennopay SDK on mobile. There is **one**
credential to manage — your HMAC signing secret — and **no** key pair to
generate.

<Note>
  Sandbox HMAC keys are issued manually during onboarding for v1. Email
  **[partners@zennopay.com](mailto:partners@zennopay.com)** to receive a sandbox key ID and signing secret.
</Note>

<Info>
  **Environments.** Point everything at the sandbox host
  (`https://api.sandbox.zennopay.com`) while integrating; swap to production
  (`https://api.zennopay.com`) at go-live. Sandbox HMAC keys are rejected by
  production and vice versa. See [Environments](/api-reference/environments).

  The older `api.zennopay.in` / `api.sandbox.zennopay.in` hosts are **still
  served** for existing integrations — `zennopay.com` is simply the canonical
  domain for new work.
</Info>

## 1. Get your credentials

You will receive exactly two things:

* A **key ID** (e.g. `acme_sandbox_2026q2`).
* A 32-byte **signing secret** for HMAC-signed API calls.

That's it — **no RSA key pair, no `iss`/`kid`, no JWKS endpoint to publish**.
Zennopay mints the client session token for you. Store both values as
environment variables on your backend:

```bash theme={null}
ZENNOPAY_HMAC_KEY_ID=acme_sandbox_2026q2
ZENNOPAY_HMAC_SECRET=<your_secret>          # server only — never ship in a client
ZENNOPAY_BASE_URL=https://api.sandbox.zennopay.com
```

See [Authentication](/authentication) for the full key-management model.

## 2. Create a payment intent (and get a session token back)

Construct the canonical request string, HMAC-SHA256 it with your signing
secret, and send it with the four required headers plus an `Idempotency-Key`.
The body carries your **opaque** user ID, the authorized USD amount in cents,
the corridor, and your per-payment KYC + sanctions attestations.

<CodeGroup>
  ```bash cURL (Sandbox) theme={null}
  curl -X POST https://api.sandbox.zennopay.com/v1/payment_intents \
    -H "X-Zennopay-Key-Id: acme_sandbox_2026q2" \
    -H "X-Zennopay-Timestamp: 2026-05-21T14:30:00Z" \
    -H "X-Zennopay-Nonce: a1b2c3d4e5f6789012345678abcdef00a1b2c3d4e5f6789012345678abcdef00" \
    -H "X-Zennopay-Signature: <base64_hmac_sha256>" \
    -H "Idempotency-Key: 6f1c2b3a-..." \
    -H "Content-Type: application/json" \
    -d '{
      "partner_user_id": "usr_8f3ka92m",
      "amount_usd_cents": 345,
      "corridor": "th_promptpay",
      "kyc_attestation": { "verified": true, "method": "your_kyc_v2", "verified_at": "2026-05-21T13:30:00Z", "id_type": "passport", "id_country": "IN" },
      "sanctions_attestation": { "clean": true, "screened_at": "2026-05-21T14:25:00Z" }
    }'
  ```

  ```typescript Node theme={null}
  import crypto from "node:crypto";

  const BASE_URL = process.env.ZENNOPAY_BASE_URL!;   // https://api.sandbox.zennopay.com
  const KEY_ID = process.env.ZENNOPAY_HMAC_KEY_ID!;  // acme_sandbox_2026q2
  const SECRET = process.env.ZENNOPAY_HMAC_SECRET!;  // <your_secret> — server only

  const method = "POST";
  const path = "/v1/payment_intents";
  const timestamp = new Date().toISOString();
  const nonce = crypto.randomBytes(32).toString("hex"); // 64 hex chars
  const body = JSON.stringify({
    partner_user_id: "usr_8f3ka92m",
    amount_usd_cents: 345,
    corridor: "th_promptpay",
    kyc_attestation: {
      verified: true, method: "your_kyc_v2",
      verified_at: "2026-05-21T13:30:00Z",
      id_type: "passport", id_country: "IN",
    },
    sanctions_attestation: { clean: true, screened_at: "2026-05-21T14:25:00Z" },
  });
  const bodyHash = crypto.createHash("sha256").update(body).digest("hex");

  const canonical = [method, path, timestamp, nonce, bodyHash].join("\n") + "\n";
  const signature = crypto.createHmac("sha256", SECRET).update(canonical).digest("base64");

  const res = await fetch(BASE_URL + path, {
    method,
    headers: {
      "X-Zennopay-Key-Id": KEY_ID,
      "X-Zennopay-Timestamp": timestamp,
      "X-Zennopay-Nonce": nonce,
      "X-Zennopay-Signature": signature,
      "Idempotency-Key": crypto.randomUUID(),
      "Content-Type": "application/json",
    },
    body,
  });
  const { intent_id, session_token, session_expires_at } = await res.json();
  ```
</CodeGroup>

You should receive a `201 Created`. The response carries the **`session_token`**
(and its expiry) alongside the intent — there is no second call to mint one:

```json theme={null}
{
  "intent_id": "zp_AbCd1234EfGh5678",
  "status": "created",
  "amount_usd_cents": 345,
  "corridor": "th_promptpay",
  "session_token": "zpst_9b1f…",
  "session_expires_at": 1716305700,
  "created_at": "2026-05-21T14:30:00Z"
}
```

Return `intent_id` and `session_token` to your app. See
[Build your session endpoint](/payments/session-endpoint) for a complete
Node.js route.

## 3. Present the PaymentSheet

Pass the `intent_id` and `session_token` to the Zennopay
[PaymentSheet](/payments/overview) on the user's device. The SDK renders the
whole pay experience natively, in-process — scan → amount + FX quote →
slide-to-pay → result — and delivers one typed `PaymentResult` to your
callback. No browser, no redirect, no URL scheme.

<CodeGroup>
  ```swift iOS theme={null}
  Zennopay.presentCheckout(
    from: viewController,
    intentID: "zp_AbCd1234EfGh5678",
    sessionToken: "zpst_9b1f..."
  ) { result in
    // .completed / .canceled / .failed
  }
  ```

  ```kotlin Android theme={null}
  Zennopay.presentCheckout(
    activity = this,
    intentId = "zp_AbCd1234EfGh5678",
    sessionToken = "zpst_9b1f...",
  ) { result ->
    // Completed / Canceled / Failed
  }
  ```

  ```dart Flutter theme={null}
  final result = await Zennopay.presentSheet(
    context: context,
    intentId: 'zp_AbCd1234EfGh5678',
    sessionToken: 'zpst_9b1f...',
  );
  ```

  ```tsx React Native theme={null}
  const { presentSheet } = useZennopay();
  const result = await presentSheet({
    intentId: 'zp_AbCd1234EfGh5678',
    sessionToken: 'zpst_9b1f...',
  });
  ```
</CodeGroup>

See the platform guides for the full lifecycle (camera permission, session
refresh, theming, testing):

<CardGroup cols={2}>
  <Card title="PaymentSheet overview" icon="layer-group" href="/payments/overview" />

  <Card title="Session endpoint" icon="server" href="/payments/session-endpoint" />

  <Card title="iOS" icon="apple" href="/payments/ios" />

  <Card title="Android" icon="android" href="/payments/android" />

  <Card title="Flutter" icon="mobile" href="/payments/flutter" />

  <Card title="React Native" icon="react" href="/payments/react-native" />

  <Card title="Test your integration" icon="vial" href="/payments/testing" />
</CardGroup>

## 4. Receive the webhook

After the payment settles (or fails), Zennopay POSTs a signed webhook to your
configured endpoint. See [Webhooks](/api-reference/webhooks) for the payload
shape and signature verification steps.

## Reference partner starter

The reference
[`zennopay-partner-starter`](https://github.com/Zennopay/zennopay-partner-starter)
(**v0.2.0+**) ships this exact flow — a runnable Express backend that's
**HMAC-only**, with no key pair. It reads the same three environment variables:

```bash theme={null}
ZENNOPAY_HMAC_KEY_ID=acme_sandbox_2026q2
ZENNOPAY_HMAC_SECRET=<your_secret>
ZENNOPAY_BASE_URL=https://api.sandbox.zennopay.com
```

<Note>
  **Upgrading from a pre-0.2.0 starter?** The HMAC-only flow removes the RSA
  session-signing key entirely. Delete `session_signing_key.pem`, drop the
  `JWT_ISS` / `kid` / JWKS wiring, and stop minting session JWTs — read
  `session_token` straight off the create response instead.
</Note>

## Common errors

| HTTP | `error.code`            | Likely cause                                                                  |
| ---- | ----------------------- | ----------------------------------------------------------------------------- |
| 401  | `authentication_failed` | Bad HMAC signature, expired timestamp, replayed nonce, or IP not in allowlist |
| 400  | `validation_failed`     | Missing `kyc_attestation` / `sanctions_attestation`, or a malformed body      |
| 400  | `invalid_corridor`      | Corridor must be `th_promptpay` or `vn_vietqr`                                |
| 422  | `amount_below_minimum`  | Minimum transaction value not met for the corridor                            |

All 401s use a generic message in the response body. Use the `request_id`
field to correlate with internal logs when contacting support.
