{
  "openapi": "3.1.0",
  "info": {
    "title": "Zennopay Partner API",
    "version": "1.0.0",
    "description": "Cross-border QR payment intents for Zennopay partners. All endpoints require HMAC signing on server-to-server calls; user-initiated confirm and fetch calls accept a short-lived per-session JWT (RS256, ≤10 min).",
    "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. See /api-reference/environments."
    },
    {
      "url": "https://api.sandbox.zennopay.com",
      "description": "Sandbox — integration, QA, demos. Sandbox HMAC keys only."
    }
  ],
  "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": {
                "wizz_user_id": "user_abc123",
                "amount_usd_cents": 345,
                "corridor": "th_promptpay"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Intent created",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/PaymentIntent" },
                "example": {
                  "intent_id": "zp_AbCd1234EfGh5678",
                  "status": "created",
                  "amount_usd_cents": 345,
                  "corridor": "th_promptpay",
                  "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}": {
      "get": {
        "summary": "Get payment intent",
        "description": "Fetch the current state of a payment intent. Accepts either HMAC (server-side) or a session JWT issued for this specific intent. A JWT bound to intent A cannot be used to fetch intent B.",
        "operationId": "getPaymentIntent",
        "tags": ["Payment Intents"],
        "security": [
          { "hmacAuth": [] },
          { "sessionJwt": [] }
        ],
        "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}/confirm": {
      "post": {
        "summary": "Confirm payment intent",
        "description": "Called from the Zennopay checkout web after the user confirms. **JWT-only** — HMAC is not accepted. The JWT must carry a `zennopay:intent_id` claim equal to `{intent_id}`.",
        "operationId": "confirmPaymentIntent",
        "tags": ["Payment Intents"],
        "security": [{ "sessionJwt": [] }],
        "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."
      },
      "sessionJwt": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "Partner-minted RS256 JWT. Lifetime ≤ 10 minutes, one-time-use (`jti` deduplicated), audience `zennopay-checkout`. Must include namespaced claims: `zennopay:intent_id`, `zennopay:amount_usd_cents`, `zennopay:corridor`, `zennopay:kyc_attestation`, `zennopay:sanctions_attestation`. The token's `iss` selects the partner JWKS used to verify."
      }
    },
    "parameters": {
      "HmacKeyId": {
        "name": "X-Zennopay-Key-Id",
        "in": "header",
        "required": true,
        "schema": { "type": "string", "example": "wizz_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": ["wizz_user_id", "amount_usd_cents", "corridor"],
        "additionalProperties": false,
        "properties": {
          "wizz_user_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 256,
            "description": "Partner's opaque user identifier. Zennopay never sees the underlying user PII; this is for partner-side correlation."
          },
          "amount_usd_cents": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100000000,
            "description": "Authorized amount in USD cents."
          },
          "corridor": { "$ref": "#/components/schemas/Corridor" }
        }
      },
      "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`." }
        }
      },
      "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" } } }
      }
    }
  }
}
