{
  "openapi": "3.1.0",
  "info": {
    "title": "KulPay \u2014 Public API",
    "version": "1.0.0",
    "summary": "Payment gateway for Mozambican and international payment rails.",
    "description": "KulPay is a payment gateway for Mozambique: mobile money (M-Pesa, eMola, Simo, Mkesh), cards, bank transfers, references, USSD, QR and BNPL behind one HTTP API.\n\nEvery account has a **test** and a **live** mode, selected by the API key you authenticate with. Connector credentials are bound per (account, mode, method), so sandbox traffic never touches live credentials. Test mode is deterministic: the same charge request with the same inputs produces the same outcome, so you can write end-to-end tests without depending on provider sandboxes.\n\nThe gateway accepts `POST /charges`, returns either an immediate result or a `next_action` block describing what the customer must do (redirect, OTP, QR, reference, etc.), and posts a signed webhook to `callback_url` when the payment terminalises (`payment.succeeded` or `payment.failed`).\n\n**Core conventions:**\n- Money is decimal: `\"amount\": \"19.99\"`, never integer minor units.\n- IDs are bare ULIDs/UUIDs; no `req_` / `pi_` / `cus_` typed prefixes.\n- No top-level `\"object\"` discriminator on responses.\n- Errors use [RFC 7807 Problem Details](https://www.rfc-editor.org/rfc/rfc7807) with a `code` extension.\n- Idempotent POSTs accept the `Idempotency-Key` header; replays of the same key return the original response.\n\n**Test inputs** are catalogued at [`/docs/testing`](/docs/testing).",
    "contact": {
      "name": "KulPay",
      "url": "https://kulpay.pavulla.com"
    },
    "license": {
      "name": "Proprietary \u2014 \u00a9 Pavulla Tech"
    }
  },
  "servers": [
    {
      "url": "/",
      "description": "This gateway"
    }
  ],
  "tags": [
    {
      "name": "Charges",
      "description": "Create and manage payments."
    },
    {
      "name": "Refunds",
      "description": "Refund a settled payment."
    },
    {
      "name": "Payouts",
      "description": "Send funds to a recipient."
    },
    {
      "name": "Payments",
      "description": "Read-only payment snapshots."
    },
    {
      "name": "Manual evidence",
      "description": "Customer-side submission of deposit slips and reconciliation fields for `method=manual` payments."
    },
    {
      "name": "Admin",
      "description": "Operator-only endpoints. Live on the management surface (typically `:9091`); subject to bearer auth, mTLS, and IP allowlist when configured."
    },
    {
      "name": "Audit",
      "description": "Tamper-evident in-memory audit log. Each entry hashes the previous; the verify endpoint walks the chain."
    },
    {
      "name": "Reconciliation",
      "description": "Match what a provider settled against what the gateway collected. Operator-only; lives on the management surface."
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/charges": {
      "post": {
        "tags": [
          "Charges"
        ],
        "summary": "Create a charge",
        "description": "Initiate a payment. Returns either a final outcome (`succeeded` / `failed`) or `requires_action` with a `next_action` block describing what the customer must do (redirect, OTP, QR, reference). Carries an `Idempotency-Key`-friendly contract: replays of the same `payment_id` return the original response.",
        "operationId": "createCharge",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ChargeRequest"
              },
              "examples": {
                "mobileMoney": {
                  "summary": "M-Pesa charge",
                  "value": {
                    "payment_id": "01J9X8K2Y3M4N5P6Q7R8S9T0V1",
                    "money": {
                      "amount": "100.00",
                      "currency": "MZN"
                    },
                    "source": {
                      "type": "mobile_wallet",
                      "mobile_wallet": {
                        "network": "mpesa",
                        "phone_number": "+258840000001"
                      }
                    },
                    "customer": {
                      "email": "demo@example.test",
                      "name": "Demo Customer"
                    },
                    "country": "MZ",
                    "callback_url": "https://account.example.test/webhooks/kulpay",
                    "return_url": "https://account.example.test/return"
                  }
                },
                "card": {
                  "summary": "Card charge with 3DS",
                  "value": {
                    "payment_id": "01J9X8K2Y3M4N5P6Q7R8S9T0V2",
                    "money": {
                      "amount": "1500.00",
                      "currency": "MZN"
                    },
                    "source": {
                      "type": "card"
                    },
                    "customer": {
                      "email": "demo@example.test"
                    },
                    "country": "MZ",
                    "return_url": "https://account.example.test/return"
                  }
                },
                "openAmount": {
                  "summary": "Open-amount tip jar",
                  "value": {
                    "payment_id": "01J9X8K2Y3M4N5P6Q7R8S9T0V3",
                    "money": {
                      "amount": "0",
                      "currency": "MZN"
                    },
                    "source": {
                      "type": "card"
                    },
                    "customer": {
                      "name": "Anonymous"
                    },
                    "country": "MZ",
                    "open_amount": true,
                    "min_amount": "10.00",
                    "max_amount": "10000.00",
                    "suggested_amounts": [
                      "50.00",
                      "100.00",
                      "250.00"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Charge created. Inspect `status` and `next_action` to decide whether further action is required.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChargeResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/charges/{id}/capture": {
      "post": {
        "tags": [
          "Charges"
        ],
        "summary": "Capture an authorised charge",
        "description": "Capture funds previously held by an `authorize_capture` flow. Optionally captures a smaller amount than was authorised (partial capture).",
        "operationId": "captureCharge",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChargeID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "amount": {
                    "$ref": "#/components/schemas/Money"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Capture successful.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChargeResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/InvalidState"
          }
        }
      }
    },
    "/charges/{id}/void": {
      "post": {
        "tags": [
          "Charges"
        ],
        "summary": "Void an uncaptured charge",
        "description": "Release the held funds without capturing. Only valid for charges still in `authorized` state.",
        "operationId": "voidCharge",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChargeID"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "Charge voided.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChargeResult"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/InvalidState"
          }
        }
      }
    },
    "/charges/{id}/complete-action": {
      "post": {
        "tags": [
          "Charges"
        ],
        "summary": "Complete a customer-action step",
        "description": "Tells the gateway the customer has finished an interactive step (redirect, OTP entry, QR scan). Use after the `next_action` URL returns the customer to your `return_url`.",
        "operationId": "completeChargeAction",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChargeID"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "otp": {
                    "type": "string",
                    "description": "OTP value when completing display_otp_form."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Action completed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChargeResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/InvalidState"
          }
        }
      }
    },
    "/payments/{provider_payment_id}": {
      "get": {
        "tags": [
          "Payments"
        ],
        "summary": "Read a payment snapshot",
        "description": "Returns the current state of a payment. Use this to poll when the original charge returned `next_action.type=poll`, or to reconcile after a missed webhook.",
        "operationId": "getPaymentSnapshot",
        "parameters": [
          {
            "name": "provider_payment_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Gateway-side payment identifier (the `provider_payment_id` returned in the original `ChargeResult`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Snapshot.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaymentSnapshot"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/refunds": {
      "post": {
        "tags": [
          "Refunds"
        ],
        "summary": "Create a refund",
        "description": "Refund all or part of a settled payment. Omit `amount` for a full refund.",
        "operationId": "createRefund",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RefundRequest"
              },
              "examples": {
                "fullRefund": {
                  "summary": "Full refund",
                  "value": {
                    "refund_id": "01J9X8K2Y3M4N5P6Q7R8S9T0V4",
                    "provider_payment_id": "pay_01J9X8K2Y3M4N5P6Q7R8S9T0V1",
                    "reason": "requested_by_customer"
                  }
                },
                "partialRefund": {
                  "summary": "Partial refund",
                  "value": {
                    "refund_id": "01J9X8K2Y3M4N5P6Q7R8S9T0V5",
                    "provider_payment_id": "pay_01J9X8K2Y3M4N5P6Q7R8S9T0V1",
                    "amount": {
                      "amount": "20.00",
                      "currency": "MZN"
                    },
                    "reason": "duplicate"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Refund result.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RefundResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/InvalidState"
          }
        }
      }
    },
    "/payouts": {
      "post": {
        "tags": [
          "Payouts"
        ],
        "summary": "Create a payout",
        "description": "Send funds to a recipient (mobile-wallet, bank transfer, etc.). The gateway returns an immediate `pending` and posts a webhook when the payout settles.",
        "operationId": "createPayout",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PayoutRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Payout accepted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PayoutResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/payouts/{provider_payout_id}": {
      "get": {
        "tags": [
          "Payouts"
        ],
        "summary": "Read a payout snapshot",
        "operationId": "getPayoutSnapshot",
        "parameters": [
          {
            "name": "provider_payout_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Snapshot.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PayoutSnapshot"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/checkout/{id}/manual": {
      "post": {
        "tags": [
          "Manual evidence"
        ],
        "summary": "Submit manual-payment evidence",
        "description": "Customer-side submission for `method=manual` payments. Accepts `multipart/form-data`: any non-empty text field (depositor, bank, branch, reference, note, \u2026) merges into the payment's `manual_fields` map; an optional `file` part becomes a `manualAttachment` tagged `uploaded_by: customer`. Redirects (303) back to `/checkout/{id}/processing` with `?manual_submitted=1` on success or `?manual_error=\u2026` on file validation failure. Field merge is independent of file validation \u2014 a too-large upload doesn't lose typed fields.",
        "operationId": "submitManualEvidence",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "depositor": {
                    "type": "string"
                  },
                  "bank": {
                    "type": "string"
                  },
                  "branch": {
                    "type": "string"
                  },
                  "reference": {
                    "type": "string"
                  },
                  "note": {
                    "type": "string"
                  },
                  "kind": {
                    "type": "string",
                    "default": "deposit_slip"
                  },
                  "file": {
                    "type": "string",
                    "format": "binary"
                  },
                  "_csrf": {
                    "type": "string",
                    "description": "Required when `csrf_required: true` is set."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "303": {
            "description": "Redirect back to the processing page with a flash query param."
          },
          "400": {
            "$ref": "#/components/responses/ProblemDetails"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/admin/status": {
      "get": {
        "tags": [
          "Admin"
        ],
        "summary": "Posture snapshot",
        "description": "At-a-glance live state of every security knob: keys configured, mTLS / CSRF / audit toggles, webhook secret rotation status, idempotency cache fill, audit chain head + length, lifetime counters. Cheap; safe under `admin:read`.",
        "operationId": "adminStatus",
        "responses": {
          "200": {
            "description": "Posture snapshot."
          }
        }
      }
    },
    "/v1/admin/reconciliation/statements": {
      "post": {
        "tags": [
          "Reconciliation"
        ],
        "summary": "Import a provider settlement file",
        "description": "multipart/form-data: a `file` part plus `provider` (one of the registered parsers: `mpesa`, `emola`, `stripe`, `generic`).\n\nSupply `declared_total` whenever the provider's own portal reports one. It is the only reliable check that the file parsed correctly \u2014 a parser reading the wrong column produces rows that look entirely plausible one by one, and only the sum betrays it. When the parsed total disagrees the import is **refused**, because a mis-parsed statement yields false reconciliation results.\n\nParsing is all-or-nothing: an unreadable row fails the whole import rather than being skipped, since a silently dropped row is money vanishing from the reconciliation with nobody told.",
        "operationId": "importStatement",
        "responses": {
          "201": {
            "description": "Statement imported and parsed."
          },
          "400": {
            "description": "Unknown provider, unparseable file, or declared-total mismatch. Nothing was written."
          }
        }
      },
      "get": {
        "tags": [
          "Reconciliation"
        ],
        "summary": "List imported statements",
        "operationId": "listStatements",
        "responses": {
          "200": {
            "description": "Statements, newest first."
          }
        }
      }
    },
    "/v1/admin/reconciliation/statements/{id}": {
      "get": {
        "tags": [
          "Reconciliation"
        ],
        "summary": "Statement summary with line counts and run history",
        "operationId": "getStatement",
        "responses": {
          "200": {
            "description": "Statement, per-status line counts, and every matching run over it."
          }
        }
      }
    },
    "/v1/admin/reconciliation/statements/{id}/run": {
      "post": {
        "tags": [
          "Reconciliation"
        ],
        "summary": "Run a matching pass",
        "description": "Matches every line against the account's payments in tiers: exact provider reference first, then an inferred tier on amount + method + time window that requires exactly one candidate.\n\nThe engine never guesses. Where several payments fit equally well it raises an `ambiguous_match` exception rather than picking one \u2014 a wrong automatic match silently closes a discrepancy that was real, whereas an unmatched line gets looked at.\n\nRuns are deterministic and re-runnable: the same statement matched twice produces the same result, so re-running after fixing data shows only what the fix changed. Operator resolutions are preserved across re-runs.\n\nOptional body: `amount_tolerance` (absorb fee/rounding differences) and `infer_matches` (set `false` for reference-only matching).",
        "operationId": "runReconciliation",
        "responses": {
          "200": {
            "description": "Run summary: `matched_exact`, `matched_inferred`, `exceptions`, `missing_in_provider`, `exceptions_by_reason`."
          }
        }
      }
    },
    "/v1/admin/reconciliation/statements/{id}/lines": {
      "get": {
        "tags": [
          "Reconciliation"
        ],
        "summary": "List statement lines",
        "description": "`?match_status=exception` is the exceptions queue.\n\nException reasons: `missing_in_provider` (the gateway says succeeded but the provider never settled \u2014 if goods shipped against it, that is a loss), `missing_in_gateway` (money arrived that was never credited to anyone), `amount_mismatch`, `currency_mismatch`, `duplicate_provider_line`, `status_mismatch`, `ambiguous_match`, `late_settlement`. The first two are the ones that cost real money.",
        "operationId": "listStatementLines",
        "responses": {
          "200": {
            "description": "Statement lines."
          }
        }
      }
    },
    "/v1/admin/reconciliation/statements/{id}/close": {
      "post": {
        "tags": [
          "Reconciliation"
        ],
        "summary": "Close a fully worked statement",
        "description": "Refused while any exception is still open: the point of the queue is that a human looked at every discrepancy.",
        "operationId": "closeStatement",
        "responses": {
          "200": {
            "description": "Statement closed."
          },
          "409": {
            "description": "Exceptions still open."
          }
        }
      }
    },
    "/v1/admin/reconciliation/lines/{lineID}/resolve": {
      "post": {
        "tags": [
          "Reconciliation"
        ],
        "summary": "Resolve an exception",
        "description": "Records an operator's disposition: `resolution`, an optional `payment_id` to link a payment the engine refused to guess at, and optional `notes`. The original exception reason is retained alongside the resolution \u2014 the audit question is both what the discrepancy was and who decided what to do about it.",
        "operationId": "resolveException",
        "responses": {
          "200": {
            "description": "Exception resolved."
          }
        }
      }
    },
    "/v1/admin/audit": {
      "get": {
        "tags": [
          "Audit"
        ],
        "summary": "Read the audit feed",
        "description": "Newest-first feed of every state-changing request. Each entry carries `key_id`, `action`, `target`, `ip`, `body_hash`, `status`, `outcome`, `prev_hash`, `hash`. Cap with `?limit=N` (default 100, max 1000).",
        "operationId": "listAudit",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 100,
              "maximum": 1000
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Audit entries."
          }
        }
      }
    },
    "/v1/admin/audit/verify": {
      "get": {
        "tags": [
          "Audit"
        ],
        "summary": "Verify the audit hash chain",
        "description": "Walks every audit entry chronologically and confirms that each entry's `hash` equals `sha256(canonical(entry))` AND each `prev_hash` equals the previous entry's `hash`. Returns 200 when the chain is intact, 409 with `broken_at` and `reason` otherwise.",
        "operationId": "verifyAuditChain",
        "responses": {
          "200": {
            "description": "Chain intact."
          },
          "409": {
            "description": "Tampering detected."
          }
        }
      }
    },
    "/v1/admin/webhooks/verify": {
      "post": {
        "tags": [
          "Admin"
        ],
        "summary": "Verify a signed webhook body",
        "description": "Oracle endpoint for integrators writing signing logic on the receiver side. POST a body + `X-KulPay-Signature: t=<unix>,v1=<hex>` header; the gateway runs `verifyKulPay` against its configured webhook secrets and replay tolerance. Returns 200 `{verified: true}` on success or 401 with a descriptive error code (`missing_signature`, `signature_invalid`, `signature_timestamp_outside_tolerance`).",
        "operationId": "verifyInboundSignature",
        "parameters": [
          {
            "name": "X-KulPay-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "example": "t=1715000000,v1=abcdef\u2026"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "*/*": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Signature verified against an active secret."
          },
          "401": {
            "description": "Missing / invalid / stale signature."
          },
          "503": {
            "description": "Gateway has no webhook secret configured."
          }
        }
      }
    },
    "/v1/payments/{id}/audit": {
      "get": {
        "tags": [
          "Audit"
        ],
        "summary": "Read the audit feed for one payment",
        "description": "Same shape as `/v1/admin/audit` filtered to entries whose `target` matches the path parameter.",
        "operationId": "listPaymentAudit",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 100,
              "maximum": 1000
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Audit entries for this payment."
          }
        }
      }
    },
    "/methods": {
      "get": {
        "tags": [
          "Methods"
        ],
        "summary": "List the payment rails this gateway offers",
        "description": "The machine-readable source of truth for which rails are available and how to invoke each one. **Call this first** \u2014 it is what the hosted checkout builds its picker from, so it is exactly what a customer can choose.\n\nEach entry carries `kind` (the `source.type` to send on POST /charges), `requires_phone`, the currency and country filters, amount bounds, and `connector` \u2014 the implementation behind the rail. A connector named `mock_*` does NOT move money; in live mode the checkout refuses to offer those, so a rail present in test mode may legitimately be absent in live.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The catalogue, plus the gateway's current mode.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/transactions": {
      "get": {
        "tags": [
          "Transactions"
        ],
        "summary": "List balance-transaction ledger entries",
        "description": "The double-entry ledger behind the account balance: one entry per money movement, newest first. Use it to reconcile your own books against the gateway rather than re-deriving totals from payments.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Ledger entries, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        },
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50
            }
          },
          {
            "name": "kind",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter to one entry kind."
          }
        ]
      }
    },
    "/files": {
      "get": {
        "tags": [
          "Files"
        ],
        "summary": "List uploaded files",
        "description": "Files held for this account \u2014 proof-of-payment slips, statements for reconciliation.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The file list.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Files"
        ],
        "summary": "Upload a file",
        "description": "multipart/form-data. Used for manual-payment evidence and reconciliation statement imports. Bytes are persisted, so an upload survives a restart.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  },
                  "purpose": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The stored file's metadata."
          }
        }
      }
    },
    "/files/{id}": {
      "get": {
        "tags": [
          "Files"
        ],
        "summary": "Get a file's metadata",
        "description": "Metadata only; the bytes are at /files/{id}/content.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "File metadata.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        },
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ]
      }
    },
    "/files/{id}/content": {
      "get": {
        "tags": [
          "Files"
        ],
        "summary": "Download a file's bytes",
        "description": "Returns the stored bytes with their original content type.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The file.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        },
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ]
      }
    },
    "/payments/{id}/files": {
      "get": {
        "tags": [
          "Files"
        ],
        "summary": "List files attached to a payment",
        "description": "Proof-of-payment evidence attached to a manual/cash payment.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Attached files.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        },
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ]
      }
    },
    "/v1/admin/webhooks/deliveries": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "List webhook delivery attempts",
        "description": "Every delivery the gateway has attempted, newest first \u2014 with attempt count, the response code your endpoint returned, and the error if it failed. This is how you debug a webhook your server never acknowledged.\n\nManagement surface: requires an admin key.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Deliveries, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        },
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "delivered",
                "exhausted"
              ]
            }
          },
          {
            "name": "payment_id",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 200
            }
          }
        ]
      }
    },
    "/v1/admin/webhooks/deliveries/{id}": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Get one delivery, including the signed body",
        "description": "Returns the **frozen payload** \u2014 the exact bytes the signature was computed over. Re-serialising that JSON changes key order or spacing and produces a different HMAC, which sends people hunting a signature bug that is not there. Compare against the raw body your server received.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The delivery, with `payload` as a verbatim string.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        },
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ]
      }
    },
    "/me": {
      "get": {
        "summary": "What am I authenticated as?",
        "description": "Describes the calling key: its id, label, mode, the account it acts for, and its scopes. Requires NO scope \u2014 a key may always describe itself, and gating this would mean the key most likely to be misconfigured is the one that cannot find out why.\n\nCall this first when something returns 401 or 403. It distinguishes the three causes that otherwise look identical: a key that was never sent, a key in the wrong mode (charges succeed, the dashboard you are watching stays empty), and a key acting for a different account than you expect.\n\nNever returns the secret.",
        "operationId": "whoAmI",
        "tags": [
          "Diagnostics"
        ],
        "responses": {
          "200": {
            "description": "The calling identity. Also returned, with `authenticated: false`, when the gateway has no key registry.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "authenticated": {
                      "type": "boolean",
                      "description": "False when the gateway has no key registry, so requests are unauthenticated and every scope check passes."
                    },
                    "key_id": {
                      "type": "string"
                    },
                    "label": {
                      "type": "string"
                    },
                    "mode": {
                      "type": "string",
                      "enum": [
                        "test",
                        "live"
                      ],
                      "description": "From the KEY, never the deployment: a test key on a live gateway reports test."
                    },
                    "account_id": {
                      "type": "string"
                    },
                    "account_name": {
                      "type": "string"
                    },
                    "account_status": {
                      "type": "string",
                      "description": "`suspended` here explains charges being refused with 403."
                    },
                    "scopes": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "rate_per_minute": {
                      "type": "integer",
                      "description": "This key's own limit, so a 429 can be told from the surface-wide one."
                    },
                    "note": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "authenticated",
                    "scopes"
                  ]
                }
              }
            }
          },
          "401": {
            "description": "No bearer token, or one the registry does not hold."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "opaque",
        "description": "Bearer token. The sandbox accepts any token when no `public_api_tokens` are configured (default for local dev). When tokens are configured, requests without one are rejected with 401."
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Optional client-supplied UUID. The gateway caches the response for this key for the process lifetime; replays return the cached response.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "ChargeID": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "The `provider_payment_id` of the charge."
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request body or query failed validation. The response body lists each field-level issue.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid bearer token.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "NotFound": {
        "description": "The named resource was not found.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "InvalidState": {
        "description": "The resource is not in a state that allows this operation (e.g. capturing an already-voided charge).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "IdempotencyConflict": {
        "description": "The supplied `Idempotency-Key` was previously used with a different request body.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Per-identity rate limit exceeded. `Retry-After` indicates when to retry.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "string"
            },
            "description": "Seconds to wait before retrying."
          }
        },
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "ServerError": {
        "description": "Unexpected gateway error.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "ProblemDetails": {
        "description": "RFC 7807 problem-details response. The `code` field carries one of the enum values in `#/components/schemas/ErrorCode`.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      }
    },
    "schemas": {
      "Money": {
        "type": "object",
        "required": [
          "amount",
          "currency"
        ],
        "properties": {
          "amount": {
            "type": "string",
            "pattern": "^[0-9]+(\\.[0-9]{1,4})?$",
            "description": "Decimal string in major units (e.g. \"19.99\", not 1999). Up to 4 decimal places.",
            "examples": [
              "100.00",
              "19.99"
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "ISO-4217 alpha-3 currency code.",
            "examples": [
              "MZN",
              "USD",
              "EUR"
            ]
          }
        }
      },
      "Customer": {
        "type": "object",
        "description": "End-user the payment is collected from. All fields optional individually; populate what you have.",
        "properties": {
          "organization_id": {
            "type": "string"
          },
          "user_id": {
            "type": "string"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "name": {
            "type": "string"
          },
          "tax_id": {
            "type": "string"
          }
        }
      },
      "Recipient": {
        "type": "object",
        "description": "Counterparty for a payout.",
        "properties": {
          "organization_id": {
            "type": "string"
          },
          "user_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "tax_id": {
            "type": "string"
          }
        }
      },
      "MobileWalletDetails": {
        "type": "object",
        "required": [
          "network",
          "phone_number"
        ],
        "properties": {
          "network": {
            "type": "string",
            "examples": [
              "mpesa",
              "emola",
              "mkesh"
            ]
          },
          "phone_number": {
            "type": "string",
            "description": "E.164 number.",
            "examples": [
              "+258840000001"
            ]
          }
        }
      },
      "CardDetails": {
        "type": "object",
        "properties": {
          "token": {
            "type": "string",
            "description": "Provider-side card token. Mutually exclusive with `saved_payment_id`."
          },
          "saved_payment_id": {
            "type": "string",
            "description": "Reference to a previously stored card."
          },
          "three_d_secure_url": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "ReferenceDetails": {
        "type": "object",
        "properties": {
          "network": {
            "type": "string",
            "examples": [
              "simo"
            ]
          },
          "expires_in": {
            "type": "string",
            "description": "Go duration (\"24h\")."
          },
          "entity": {
            "type": "string",
            "description": "Pin a specific entity from the catalog."
          },
          "reference": {
            "type": "string",
            "readOnly": true
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "readOnly": true
          }
        }
      },
      "BankTransferDetails": {
        "type": "object",
        "properties": {
          "iban": {
            "type": "string"
          },
          "account_number": {
            "type": "string"
          },
          "swift_bic": {
            "type": "string"
          },
          "beneficiary_name": {
            "type": "string"
          },
          "reference": {
            "type": "string"
          }
        }
      },
      "ManualDetails": {
        "type": "object",
        "required": [
          "recorded_by",
          "external_reference"
        ],
        "properties": {
          "recorded_by": {
            "type": "string",
            "description": "Operator that recorded the payment."
          },
          "external_reference": {
            "type": "string"
          },
          "notes": {
            "type": "string"
          }
        }
      },
      "MockDetails": {
        "type": "object",
        "description": "Force a specific outcome regardless of fixture matches.",
        "required": [
          "outcome"
        ],
        "properties": {
          "outcome": {
            "type": "string",
            "enum": [
              "succeed",
              "pending",
              "fail"
            ]
          },
          "failure_code": {
            "type": "string"
          },
          "delay": {
            "type": "string",
            "description": "Go duration before resolving."
          }
        }
      },
      "Method": {
        "type": "object",
        "required": [
          "type"
        ],
        "description": "Polymorphic method picker. Set `type` to one of the listed values and populate the matching nested object.",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "mobile_wallet",
              "card",
              "reference",
              "bank_transfer",
              "manual",
              "mock",
              "custom"
            ]
          },
          "mobile_wallet": {
            "$ref": "#/components/schemas/MobileWalletDetails"
          },
          "card": {
            "$ref": "#/components/schemas/CardDetails"
          },
          "reference": {
            "$ref": "#/components/schemas/ReferenceDetails"
          },
          "bank_transfer": {
            "$ref": "#/components/schemas/BankTransferDetails"
          },
          "manual": {
            "$ref": "#/components/schemas/ManualDetails"
          },
          "mock": {
            "$ref": "#/components/schemas/MockDetails"
          },
          "custom": {
            "type": "object",
            "properties": {
              "extension_type": {
                "type": "string"
              },
              "details": {
                "type": "object",
                "additionalProperties": true
              }
            }
          }
        }
      },
      "RetryPolicy": {
        "type": "object",
        "description": "Per-charge override for the gateway's outbound webhook retry behaviour. Zero/empty values mean \"no override.\"",
        "properties": {
          "max_attempts": {
            "type": "integer",
            "description": "Total attempts cap. -1 disables retry."
          },
          "initial_delay": {
            "type": "string",
            "description": "Go duration."
          },
          "max_delay": {
            "type": "string",
            "description": "Go duration."
          },
          "backoff_multiplier": {
            "type": "number",
            "format": "double"
          },
          "retry_on": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "examples": [
              [
                "5xx",
                "408",
                "429"
              ]
            ]
          }
        }
      },
      "ChargeRequest": {
        "type": "object",
        "required": [
          "payment_id",
          "money",
          "source"
        ],
        "properties": {
          "payment_id": {
            "type": "string",
            "description": "Caller-assigned identifier. Must be unique per charge; replays of the same value return the original response."
          },
          "money": {
            "$ref": "#/components/schemas/Money"
          },
          "source": {
            "$ref": "#/components/schemas/Method"
          },
          "customer": {
            "$ref": "#/components/schemas/Customer"
          },
          "country": {
            "type": "string",
            "description": "ISO-3166 alpha-2. Drives method filtering and default reference entity.",
            "examples": [
              "MZ",
              "PT",
              "AO"
            ]
          },
          "description": {
            "type": "string",
            "maxLength": 500
          },
          "callback_url": {
            "type": "string",
            "format": "uri"
          },
          "return_url": {
            "type": "string",
            "format": "uri"
          },
          "metadata": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Up to 50 key/value pairs (key max 100 chars, value max 500 chars)."
          },
          "open_amount": {
            "type": "boolean",
            "description": "Customer picks the amount at checkout."
          },
          "min_amount": {
            "type": "string"
          },
          "max_amount": {
            "type": "string"
          },
          "suggested_amounts": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "allowed_sources": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "blocked_sources": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "retry_policy": {
            "$ref": "#/components/schemas/RetryPolicy"
          }
        }
      },
      "NextAction": {
        "type": "object",
        "description": "What the customer must do before the charge can resolve.",
        "required": [
          "type"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "redirect_to_url",
              "display_otp_form",
              "display_qr_code",
              "display_reference",
              "display_bnpl_redirect",
              "poll",
              "authorize_capture"
            ]
          },
          "redirect_url": {
            "type": "string",
            "format": "uri"
          },
          "return_url": {
            "type": "string",
            "format": "uri"
          },
          "otp_hint": {
            "type": "string"
          },
          "otp_length": {
            "type": "integer"
          },
          "masked_dest": {
            "type": "string"
          },
          "qr_code_url": {
            "type": "string",
            "format": "uri"
          },
          "qr_code_data": {
            "type": "string"
          },
          "entity": {
            "type": "string"
          },
          "reference": {
            "type": "string"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time"
          },
          "poll_interval_secs": {
            "type": "integer"
          },
          "authorization_code": {
            "type": "string"
          },
          "auth_expires_at": {
            "type": "string",
            "format": "date-time"
          },
          "bnpl_provider": {
            "type": "string"
          }
        }
      },
      "ChargeResult": {
        "type": "object",
        "required": [
          "payment_id",
          "provider_payment_id",
          "status",
          "money",
          "processed_at"
        ],
        "properties": {
          "payment_id": {
            "type": "string"
          },
          "provider_payment_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "requires_action",
              "succeeded",
              "failed",
              "expired",
              "authorized",
              "voided"
            ]
          },
          "money": {
            "$ref": "#/components/schemas/Money"
          },
          "source": {
            "$ref": "#/components/schemas/Method"
          },
          "checkout_url": {
            "type": "string",
            "format": "uri"
          },
          "next_action": {
            "$ref": "#/components/schemas/NextAction"
          },
          "authorized_amount": {
            "type": "string"
          },
          "captured_amount": {
            "type": "string"
          },
          "failure_code": {
            "type": "string"
          },
          "failure_message": {
            "type": "string"
          },
          "processed_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "RefundRequest": {
        "type": "object",
        "required": [
          "refund_id",
          "provider_payment_id"
        ],
        "properties": {
          "refund_id": {
            "type": "string",
            "description": "Caller-assigned identifier; replays return the original response."
          },
          "provider_payment_id": {
            "type": "string"
          },
          "amount": {
            "$ref": "#/components/schemas/Money"
          },
          "reason": {
            "type": "string",
            "enum": [
              "duplicate",
              "fraudulent",
              "requested_by_customer",
              "expired_uncaptured",
              "other"
            ]
          }
        }
      },
      "RefundResult": {
        "type": "object",
        "required": [
          "refund_id",
          "provider_refund_id",
          "status",
          "money",
          "processed_at"
        ],
        "properties": {
          "refund_id": {
            "type": "string"
          },
          "provider_refund_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "succeeded",
              "failed"
            ]
          },
          "money": {
            "$ref": "#/components/schemas/Money"
          },
          "failure_code": {
            "type": "string"
          },
          "failure_message": {
            "type": "string"
          },
          "processed_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PayoutRequest": {
        "type": "object",
        "required": [
          "payout_id",
          "money",
          "destination"
        ],
        "properties": {
          "payout_id": {
            "type": "string"
          },
          "money": {
            "$ref": "#/components/schemas/Money"
          },
          "destination": {
            "$ref": "#/components/schemas/Method"
          },
          "recipient": {
            "$ref": "#/components/schemas/Recipient"
          },
          "description": {
            "type": "string"
          },
          "callback_url": {
            "type": "string",
            "format": "uri"
          },
          "metadata": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          }
        }
      },
      "PayoutResult": {
        "type": "object",
        "required": [
          "payout_id",
          "provider_payout_id",
          "status",
          "money",
          "processed_at"
        ],
        "properties": {
          "payout_id": {
            "type": "string"
          },
          "provider_payout_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "succeeded",
              "failed"
            ]
          },
          "money": {
            "$ref": "#/components/schemas/Money"
          },
          "failure_code": {
            "type": "string"
          },
          "failure_message": {
            "type": "string"
          },
          "processed_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PaymentSnapshot": {
        "type": "object",
        "required": [
          "payment_id",
          "provider_payment_id",
          "status",
          "money",
          "last_updated_at"
        ],
        "properties": {
          "payment_id": {
            "type": "string"
          },
          "provider_payment_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "requires_action",
              "succeeded",
              "failed",
              "expired",
              "authorized",
              "voided"
            ]
          },
          "money": {
            "$ref": "#/components/schemas/Money"
          },
          "source": {
            "$ref": "#/components/schemas/Method"
          },
          "checkout_url": {
            "type": "string",
            "format": "uri"
          },
          "next_action": {
            "$ref": "#/components/schemas/NextAction"
          },
          "authorized_amount": {
            "type": "string"
          },
          "captured_amount": {
            "type": "string"
          },
          "failure_code": {
            "type": "string"
          },
          "failure_message": {
            "type": "string"
          },
          "last_updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PayoutSnapshot": {
        "type": "object",
        "required": [
          "payout_id",
          "provider_payout_id",
          "status",
          "money",
          "last_updated_at"
        ],
        "properties": {
          "payout_id": {
            "type": "string"
          },
          "provider_payout_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "succeeded",
              "failed"
            ]
          },
          "money": {
            "$ref": "#/components/schemas/Money"
          },
          "destination": {
            "$ref": "#/components/schemas/Method"
          },
          "failure_code": {
            "type": "string"
          },
          "failure_message": {
            "type": "string"
          },
          "last_updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ErrorCode": {
        "type": "string",
        "description": "Stable machine-readable error code emitted in `Problem.code` (and the `error` field of legacy non-RFC-7807 responses). Codes are grouped by surface so an integrator's switch statement can fall through to a sensible default branch.",
        "enum": [
          "invalid_request",
          "invalid_amount",
          "invalid_currency",
          "validation_failed",
          "not_found",
          "invalid_state",
          "unauthorized",
          "key_expired",
          "ip_not_allowed",
          "insufficient_scope",
          "mode_mismatch",
          "missing_signature",
          "signature_invalid",
          "signature_timestamp_outside_tolerance",
          "csrf_missing",
          "csrf_invalid",
          "idempotent_conflict",
          "invalid_idempotency_key",
          "rate_limited",
          "missing_file",
          "file_too_large",
          "unsupported_content_type",
          "too_many_attachments",
          "no_secret_configured"
        ]
      },
      "Problem": {
        "type": "object",
        "description": "RFC 7807 problem-details document with a KulPay `code` extension.",
        "required": [
          "title",
          "status",
          "code"
        ],
        "properties": {
          "type": {
            "type": "string",
            "format": "uri",
            "default": "about:blank"
          },
          "title": {
            "type": "string"
          },
          "status": {
            "type": "integer"
          },
          "code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "detail": {
            "type": "string"
          },
          "instance": {
            "type": "string",
            "format": "uri"
          },
          "errors": {
            "type": "array",
            "description": "Field-level error list for validation failures.",
            "items": {
              "type": "object",
              "properties": {
                "field": {
                  "type": "string"
                },
                "code": {
                  "type": "string"
                },
                "message": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "WebhookEvent": {
        "type": "object",
        "description": "The signed envelope POSTed to your callback_url. Verify X-KulPay-Signature against the RAW body before trusting any field \u2014 see /docs#webhooks.\n\nOnly `payment.succeeded` means you have been paid. `processing` and `requires_action` are progress reports; acting on them is the most common integration mistake.",
        "required": [
          "id",
          "type",
          "created_at",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Stable per (endpoint, payment, event). Use it to deduplicate: retries reuse it.",
            "example": "3H7f\u2026:3H7g\u2026:payment.succeeded"
          },
          "type": {
            "type": "string",
            "enum": [
              "payment.succeeded",
              "payment.failed",
              "payment.cancelled",
              "payment.expired",
              "payment.processing",
              "payment.requires_action",
              "refund.succeeded",
              "refund.failed"
            ],
            "description": "Terminal: succeeded, failed, cancelled, expired, refund.*. Non-terminal: processing, requires_action."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "data": {
            "type": "object",
            "description": "The payment or refund at the moment the event fired."
          }
        }
      }
    }
  }
}
