{
  "openapi": "3.1.0",
  "info": {
    "title": "Zennopay Partner API",
    "version": "1.1.0",
    "description": "Cross-border QR payment intents for Zennopay partners. Server-to-server calls are HMAC-signed. Zennopay mints a short-lived session token on intent creation and returns it as `session_token`; the SDK sends it as a bearer credential on user-initiated scan/confirm/fetch calls, and your backend re-mints it via POST /v1/payment_intents/{intent_id}/session.",
    "contact": {
      "name": "Zennopay Support",
      "email": "support@zennopay.com",
      "url": "https://zennopay.com"
    }
  },
  "servers": [
    {
      "url": "https://api.zennopay.com",
      "description": "Production — live partner traffic, real money. The legacy host https://api.zennopay.in continues to be served for existing integrations. See /api-reference/environments."
    },
    {
      "url": "https://api.sandbox.zennopay.com",
      "description": "Sandbox — integration, QA, demos. Sandbox HMAC keys only. The legacy host https://api.sandbox.zennopay.in continues to be served for existing integrations."
    }
  ],
  "security": [
    { "hmacAuth": [] }
  ],
  "tags": [
    {
      "name": "Payment Intents",
      "description": "Create, fetch, confirm, and cancel single-use payment intents."
    }
  ],
  "paths": {
    "/v1/payment_intents": {
      "post": {
        "summary": "Create payment intent",
        "description": "Create a new payment intent. Server-to-server only; requires HMAC signing.",
        "operationId": "createPaymentIntent",
        "tags": ["Payment Intents"],
        "security": [{ "hmacAuth": [] }],
        "parameters": [
          { "$ref": "#/components/parameters/HmacKeyId" },
          { "$ref": "#/components/parameters/HmacTimestamp" },
          { "$ref": "#/components/parameters/HmacNonce" },
          { "$ref": "#/components/parameters/HmacSignature" },
          { "$ref": "#/components/parameters/IdempotencyKey" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/CreateIntentRequest" },
              "example": {
                "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" }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Intent created. Response carries the Zennopay-minted session token.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CreatedPaymentIntent" },
                "example": {
                  "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"
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationFailed" },
          "401": { "$ref": "#/components/responses/AuthenticationFailed" },
          "403": { "$ref": "#/components/responses/AuthorizationFailed" }
        }
      }
    },
    "/v1/payment_intents/{intent_id}/session": {
      "post": {
        "summary": "Re-mint session token",
        "description": "Mint a fresh session token for an existing intent. Server-to-server only; requires HMAC. The partner identified by the HMAC key must own the intent, and it must not be in a terminal state. Zennopay preserves the intent's scan/quote/confirm state across the re-mint. Drives the SDK's `refreshSession` hook.",
        "operationId": "remintSessionToken",
        "tags": ["Payment Intents"],
        "security": [{ "hmacAuth": [] }],
        "parameters": [
          { "$ref": "#/components/parameters/IntentIdPath" },
          { "$ref": "#/components/parameters/HmacKeyId" },
          { "$ref": "#/components/parameters/HmacTimestamp" },
          { "$ref": "#/components/parameters/HmacNonce" },
          { "$ref": "#/components/parameters/HmacSignature" }
        ],
        "responses": {
          "200": {
            "description": "Fresh session token minted",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SessionToken" },
                "example": {
                  "intent_id": "zp_AbCd1234EfGh5678",
                  "session_token": "zpst_c4a7…",
                  "session_expires_at": 1716306000
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/AuthenticationFailed" },
          "403": { "$ref": "#/components/responses/AuthorizationFailed" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" }
        }
      }
    },
    "/v1/payment_intents/{intent_id}/receipt_token": {
      "post": {
        "summary": "Mint a receipt token",
        "description": "Mint a read-only, reusable receipt token so the SDK can reopen the authoritative receipt for a past payment. Server-to-server only; requires HMAC and **no request body**. The partner identified by the HMAC key must own `{intent_id}`; a cross-partner or unknown intent id returns an identical `404` with no existence leak. The token's `sub` is derived from the stored intent's `partner_user_id` (not supplied by the caller), so the token is scoped to that user and can open any of that user's receipts — but no one else's.",
        "operationId": "mintReceiptToken",
        "tags": ["Payment Intents"],
        "security": [{ "hmacAuth": [] }],
        "parameters": [
          { "$ref": "#/components/parameters/IntentIdPath" },
          { "$ref": "#/components/parameters/HmacKeyId" },
          { "$ref": "#/components/parameters/HmacTimestamp" },
          { "$ref": "#/components/parameters/HmacNonce" },
          { "$ref": "#/components/parameters/HmacSignature" }
        ],
        "responses": {
          "200": {
            "description": "Receipt token minted",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ReceiptToken" },
                "example": {
                  "receipt_token": "zprt_2f9c…",
                  "receipt_token_expires_at": 1716306300
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/AuthenticationFailed" },
          "403": { "$ref": "#/components/responses/AuthorizationFailed" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/v1/payment_intents/{intent_id}": {
      "get": {
        "summary": "Get payment intent",
        "description": "Fetch the current state of a payment intent. Accepts either HMAC (server-side) or the session token issued for this specific intent. A session token bound to intent A cannot be used to fetch intent B.",
        "operationId": "getPaymentIntent",
        "tags": ["Payment Intents"],
        "security": [
          { "hmacAuth": [] },
          { "sessionToken": [] }
        ],
        "parameters": [
          { "$ref": "#/components/parameters/IntentIdPath" }
        ],
        "responses": {
          "200": {
            "description": "Intent fetched",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/PaymentIntent" },
                "example": {
                  "intent_id": "zp_AbCd1234EfGh5678",
                  "status": "captured",
                  "amount_usd_cents": 345,
                  "corridor": "th_promptpay",
                  "created_at": "2026-05-21T14:30:00Z",
                  "captured_at": "2026-05-21T14:31:12Z"
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/AuthenticationFailed" },
          "403": { "$ref": "#/components/responses/AuthorizationFailed" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/v1/payment_intents/{intent_id}/receipt": {
      "get": {
        "summary": "Get receipt",
        "description": "Fetch the authoritative, branded receipt for a payment intent with its live status. Called by the Zennopay SDK inside `presentReceipt`; partners usually don't call it directly. **Receipt-token only** — authorize with a Zennopay-minted receipt token (from POST /v1/payment_intents/{intent_id}/receipt_token) sent as a bearer credential. The token is read-only, reusable, and scoped to a user (not intent-bound). A receipt token scoped to one user returns `404` for any intent that isn't that user's, with no existence leak.",
        "operationId": "getReceipt",
        "tags": ["Payment Intents"],
        "security": [{ "receiptToken": [] }],
        "parameters": [
          { "$ref": "#/components/parameters/IntentIdPath" }
        ],
        "responses": {
          "200": {
            "description": "Receipt fetched",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Receipt" },
                "example": {
                  "intent_id": "zp_AbCd1234EfGh5678",
                  "status": "captured",
                  "amount_usd_cents": 14000,
                  "corridor": "vn_vietqr",
                  "local_amount": "3500000",
                  "merchant_name": "Cong Ca Phe",
                  "destination_masked": "••• 4321",
                  "created_at": "2026-05-21T14:30:00Z",
                  "captured_at": "2026-05-21T14:31:12Z"
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/AuthenticationFailed" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/v1/payment_intents/{intent_id}/confirm": {
      "post": {
        "summary": "Confirm payment intent",
        "description": "Called by the Zennopay SDK after the user slides to pay. **Session-token only** — HMAC is not accepted. The session token must be the one issued for `{intent_id}`.",
        "operationId": "confirmPaymentIntent",
        "tags": ["Payment Intents"],
        "security": [{ "sessionToken": [] }],
        "parameters": [
          { "$ref": "#/components/parameters/IntentIdPath" }
        ],
        "responses": {
          "200": {
            "description": "Intent confirmed",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/PaymentIntent" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/AuthenticationFailed" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" }
        }
      }
    },
    "/v1/payment_intents/{intent_id}/cancel": {
      "post": {
        "summary": "Cancel payment intent",
        "description": "Cancel a not-yet-captured intent. Server-to-server only; requires HMAC. The partner identified by the HMAC key must own the intent.",
        "operationId": "cancelPaymentIntent",
        "tags": ["Payment Intents"],
        "security": [{ "hmacAuth": [] }],
        "parameters": [
          { "$ref": "#/components/parameters/IntentIdPath" },
          { "$ref": "#/components/parameters/HmacKeyId" },
          { "$ref": "#/components/parameters/HmacTimestamp" },
          { "$ref": "#/components/parameters/HmacNonce" },
          { "$ref": "#/components/parameters/HmacSignature" }
        ],
        "responses": {
          "200": {
            "description": "Intent cancelled",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/PaymentIntent" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/AuthenticationFailed" },
          "403": { "$ref": "#/components/responses/AuthorizationFailed" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "hmacAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Zennopay-Signature",
        "description": "HMAC-SHA256 signature scheme. Four headers are required on every signed request: `X-Zennopay-Key-Id`, `X-Zennopay-Timestamp` (RFC 3339, ±5 min skew), `X-Zennopay-Nonce` (32-byte hex), and `X-Zennopay-Signature` (base64 HMAC-SHA256 of the canonical request: `METHOD\\nPATH\\nTIMESTAMP\\nNONCE\\nSHA256_HEX(body)`). Requests must originate from an allowlisted source IP."
      },
      "sessionToken": {
        "type": "http",
        "scheme": "bearer",
        "description": "Zennopay-minted session token. Returned as `session_token` from POST /v1/payment_intents (and re-minted via POST /v1/payment_intents/{intent_id}/session). Short-lived, single-use for the confirming debit, and scoped to one intent. Opaque to partners — treat it as a bearer secret."
      },
      "receiptToken": {
        "type": "http",
        "scheme": "bearer",
        "description": "Zennopay-minted receipt token for reopening a receipt. Returned as `receipt_token` from POST /v1/payment_intents/{intent_id}/receipt_token. Audience `zennopay-receipt`, TTL 300s, read-only (authorizes only GET /receipt), reusable, and scoped to a user (not intent-bound) — it can open any of that user's receipts but no one else's."
      }
    },
    "parameters": {
      "HmacKeyId": {
        "name": "X-Zennopay-Key-Id",
        "in": "header",
        "required": true,
        "schema": { "type": "string", "example": "acme_sandbox_2026q2" },
        "description": "Identifies the signing key. Format `{partner}_{env}_{quarter}`."
      },
      "HmacTimestamp": {
        "name": "X-Zennopay-Timestamp",
        "in": "header",
        "required": true,
        "schema": { "type": "string", "format": "date-time", "example": "2026-05-21T14:30:00Z" },
        "description": "RFC 3339 UTC timestamp. Rejected if more than ±5 minutes off server time."
      },
      "HmacNonce": {
        "name": "X-Zennopay-Nonce",
        "in": "header",
        "required": true,
        "schema": { "type": "string", "minLength": 64, "maxLength": 64, "pattern": "^[0-9a-f]{64}$" },
        "description": "Random 32-byte hex string (64 chars). Replays within 10 minutes are rejected."
      },
      "HmacSignature": {
        "name": "X-Zennopay-Signature",
        "in": "header",
        "required": true,
        "schema": { "type": "string" },
        "description": "Base64-encoded HMAC-SHA256 of the canonical request string."
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "schema": { "type": "string", "maxLength": 256 },
        "description": "Optional client-supplied idempotency key. Replaying the exact same request with the same key returns the original response."
      },
      "IntentIdPath": {
        "name": "intent_id",
        "in": "path",
        "required": true,
        "schema": { "type": "string", "example": "zp_AbCd1234EfGh5678" },
        "description": "Zennopay payment intent ID."
      }
    },
    "schemas": {
      "Corridor": {
        "type": "string",
        "enum": ["th_promptpay", "vn_vietqr"],
        "description": "Destination payout corridor. `th_promptpay` = Thailand PromptPay. `vn_vietqr` = Vietnam VietQR."
      },
      "IntentStatus": {
        "type": "string",
        "enum": ["created", "authorized", "captured", "failed", "expired", "refunded", "cancelled"],
        "description": "Lifecycle state of the payment intent."
      },
      "CreateIntentRequest": {
        "type": "object",
        "required": ["partner_user_id", "amount_usd_cents", "corridor", "kyc_attestation", "sanctions_attestation"],
        "additionalProperties": false,
        "description": "Create-intent body. `kyc_attestation` and `sanctions_attestation` are required here because Zennopay mints the session token from the intent.",
        "properties": {
          "partner_user_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 256,
            "description": "Partner's opaque, stable user identifier — never a raw government ID. Zennopay never sees the underlying user PII; this is for partner-side correlation and per-user limit enforcement."
          },
          "amount_usd_cents": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100000000,
            "description": "Authorized amount in USD cents."
          },
          "corridor": { "$ref": "#/components/schemas/Corridor" },
          "kyc_attestation": { "$ref": "#/components/schemas/KycAttestation" },
          "sanctions_attestation": { "$ref": "#/components/schemas/SanctionsAttestation" }
        }
      },
      "KycAttestation": {
        "type": "object",
        "required": ["verified", "method", "verified_at"],
        "description": "Partner's per-payment KYC attestation, from the partner's real KYC system.",
        "properties": {
          "verified": { "type": "boolean", "description": "Must be true; Zennopay will not move money otherwise." },
          "method": { "type": "string", "description": "Partner's KYC method identifier, e.g. \"your_kyc_v2\"." },
          "verified_at": { "type": "string", "format": "date-time", "description": "When the user was KYC-verified (ISO 8601)." },
          "id_type": { "type": "string", "description": "Which government ID the user was KYC'd on, e.g. \"passport\". The raw ID number never crosses." },
          "id_country": { "type": "string", "description": "ISO country of the government ID, e.g. \"IN\"." }
        }
      },
      "SanctionsAttestation": {
        "type": "object",
        "required": ["clean", "screened_at"],
        "description": "Partner's per-payment sanctions attestation, from the partner's real screening system.",
        "properties": {
          "clean": { "type": "boolean", "description": "Must be true; Zennopay will not move money otherwise." },
          "screened_at": { "type": "string", "format": "date-time", "description": "When the user was last sanctions-screened (ISO 8601)." }
        }
      },
      "SessionToken": {
        "type": "object",
        "required": ["intent_id", "session_token", "session_expires_at"],
        "description": "A re-minted session token for an existing intent.",
        "properties": {
          "intent_id": { "type": "string", "example": "zp_AbCd1234EfGh5678" },
          "session_token": { "type": "string", "description": "Zennopay-minted bearer session token for the SDK.", "example": "zpst_c4a7…" },
          "session_expires_at": { "type": "integer", "description": "Unix epoch seconds at which the token stops verifying.", "example": 1716306000 }
        }
      },
      "ReceiptToken": {
        "type": "object",
        "required": ["receipt_token", "receipt_token_expires_at"],
        "description": "A read-only, reusable, user-scoped receipt token.",
        "properties": {
          "receipt_token": { "type": "string", "description": "Zennopay-minted bearer receipt token for the SDK's presentReceipt. Audience `zennopay-receipt`, TTL 300s.", "example": "zprt_2f9c…" },
          "receipt_token_expires_at": { "type": "integer", "description": "Unix epoch seconds at which the token stops verifying.", "example": 1716306300 }
        }
      },
      "CreatedPaymentIntent": {
        "type": "object",
        "required": ["intent_id", "status", "amount_usd_cents", "corridor", "session_token", "session_expires_at", "created_at"],
        "description": "Create-intent response. Extends PaymentIntent with the Zennopay-minted session token.",
        "properties": {
          "intent_id": { "type": "string", "example": "zp_AbCd1234EfGh5678" },
          "status": { "$ref": "#/components/schemas/IntentStatus" },
          "amount_usd_cents": { "type": "integer" },
          "corridor": { "$ref": "#/components/schemas/Corridor" },
          "session_token": { "type": "string", "description": "Zennopay-minted bearer session token. Return it to your app for the SDK.", "example": "zpst_9b1f…" },
          "session_expires_at": { "type": "integer", "description": "Unix epoch seconds at which the token stops verifying.", "example": 1716305700 },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "PaymentIntent": {
        "type": "object",
        "required": ["intent_id", "status", "amount_usd_cents", "corridor", "created_at"],
        "properties": {
          "intent_id": {
            "type": "string",
            "description": "Zennopay intent ID, format `zp_...`.",
            "example": "zp_AbCd1234EfGh5678"
          },
          "status": { "$ref": "#/components/schemas/IntentStatus" },
          "amount_usd_cents": { "type": "integer" },
          "corridor": { "$ref": "#/components/schemas/Corridor" },
          "created_at": { "type": "string", "format": "date-time" },
          "captured_at": { "type": "string", "format": "date-time", "description": "Present only when `status` is `captured`." }
        }
      },
      "Receipt": {
        "type": "object",
        "required": ["intent_id", "status", "amount_usd_cents", "corridor", "created_at"],
        "description": "Authoritative receipt for a payment, with live status. Returned by GET /v1/payment_intents/{intent_id}/receipt.",
        "properties": {
          "intent_id": { "type": "string", "example": "zp_AbCd1234EfGh5678" },
          "status": {
            "type": "string",
            "enum": ["pending", "captured", "failed", "refunded"],
            "description": "Live status at fetch time. Poll a `pending` receipt to a terminal state."
          },
          "amount_usd_cents": { "type": "integer", "description": "USD amount debited from the user's wallet." },
          "corridor": { "$ref": "#/components/schemas/Corridor" },
          "local_amount": { "type": "string", "description": "Amount delivered to the merchant in local currency (minor-unit string), e.g. \"3500000\" VND." },
          "merchant_name": { "type": "string", "description": "Merchant display name from the scanned QR." },
          "destination_masked": { "type": "string", "description": "Masked destination account, e.g. \"••• 4321\"." },
          "created_at": { "type": "string", "format": "date-time" },
          "captured_at": { "type": "string", "format": "date-time", "description": "Present only when `status` is `captured`." },
          "refund": {
            "type": "object",
            "description": "Present only when `status` is `refunded`.",
            "properties": {
              "amount_usd_cents": { "type": "integer" },
              "refunded_at": { "type": "string", "format": "date-time" }
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error", "message"],
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "authentication_failed",
              "authorization_failed",
              "validation_failed",
              "not_found",
              "conflict",
              "internal_error"
            ]
          },
          "message": { "type": "string", "description": "Generic, human-readable summary. Internal failure reasons are not exposed." }
        }
      }
    },
    "responses": {
      "AuthenticationFailed": {
        "description": "401 — Missing or invalid HMAC/JWT credentials.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "authentication_failed", "message": "authentication failed" } } }
      },
      "AuthorizationFailed": {
        "description": "403 — Credentials valid but not authorized for this resource.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "authorization_failed", "message": "not authorized" } } }
      },
      "ValidationFailed": {
        "description": "400 — Request body or parameters failed validation.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "validation_failed", "message": "request invalid" } } }
      },
      "NotFound": {
        "description": "404 — Resource not found.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "not_found", "message": "intent not found" } } }
      },
      "Conflict": {
        "description": "409 — Operation conflicts with current resource state.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "conflict", "message": "intent state does not allow this operation" } } }
      }
    }
  }
}
