{
  "openapi": "3.0.3",
  "info": {
    "title": "Bayar Partner API",
    "version": "1.0.0",
    "description": "Partner-facing REST API for the Bayar payment orchestrator. These are the endpoints the Partner Dashboard consumes, authenticated with a partner API key. This spec is hand-authored to describe the existing handlers in `internal/api` and `cmd/bayar/serve.go`; keep it in sync when those change.\n",
    "contact": {
      "name": "Bayar Partner Engineering",
      "email": "partners@bayar.example"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://bayar.example/legal/api-license"
    }
  },
  "servers": [
    {
      "url": "https://api.beever.xyz/v1",
      "description": "Production"
    },
    {
      "url": "https://sandbox.beever.xyz/v1",
      "description": "Sandbox — simulated PSPs, no real money"
    },
    {
      "url": "/v1",
      "description": "Relative, for a client that reaches Bayar through its own reverse proxy (this is how the Partner Dashboard's BFF addresses it)."
    }
  ],
  "security": [
    {
      "apiKey": []
    }
  ],
  "tags": [
    {
      "name": "Account",
      "description": "The authenticated partner: who the calling key belongs to, and the account-level settings that shape every other response.\n"
    },
    {
      "name": "Payments",
      "description": "Create, list, and inspect collections and payouts."
    },
    {
      "name": "Balances",
      "description": "Per-currency held/reserved/available balances."
    },
    {
      "name": "Settlements",
      "description": "Payout settlements (a filtered view of payments)."
    },
    {
      "name": "Beneficiaries",
      "description": "Payout destinations (bank accounts, wallets, cards, and similar)."
    },
    {
      "name": "Disputes",
      "description": "Chargebacks, returns, inquiries, and retrievals against payments."
    }
  ],
  "paths": {
    "/me": {
      "get": {
        "tags": [
          "Account"
        ],
        "operationId": "getMe",
        "summary": "The authenticated partner's own profile",
        "responses": {
          "200": {
            "description": "Partner profile",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Partner"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/payments": {
      "get": {
        "tags": [
          "Payments"
        ],
        "operationId": "listPayments",
        "summary": "List payments (cursor-paginated)",
        "parameters": [
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "in": "query",
            "name": "cursor",
            "schema": {
              "type": "string"
            },
            "description": "Opaque cursor from a prior response's next_cursor"
          },
          {
            "in": "query",
            "name": "state",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "currency",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "method",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "direction",
            "schema": {
              "$ref": "#/components/schemas/Direction"
            }
          },
          {
            "$ref": "#/components/parameters/From"
          },
          {
            "$ref": "#/components/parameters/To"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of payments",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ListPaymentsResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "Payments"
        ],
        "operationId": "createPayment",
        "summary": "Create a payment (collection or payout)",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreatePaymentRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Payment"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/payments/{id}": {
      "get": {
        "tags": [
          "Payments"
        ],
        "operationId": "getPayment",
        "summary": "Get a payment by id (or ?merchant_reference=)",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "merchant_reference",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The payment",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Payment"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/payments/{id}/timeline": {
      "get": {
        "tags": [
          "Payments"
        ],
        "operationId": "getPaymentTimeline",
        "summary": "A payment's ordered state-transition history",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The payment's transitions, chronological",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaymentTimelineResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/payments/export": {
      "get": {
        "tags": [
          "Payments"
        ],
        "operationId": "exportPayments",
        "summary": "Export payments as CSV",
        "parameters": [
          {
            "in": "query",
            "name": "format",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "csv"
              ]
            }
          },
          {
            "$ref": "#/components/parameters/From"
          },
          {
            "$ref": "#/components/parameters/To"
          },
          {
            "in": "query",
            "name": "state",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "direction",
            "schema": {
              "$ref": "#/components/schemas/Direction"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "CSV stream",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "description": "The window would export more than 10,000 rows. Narrow it via from/to. Code `export_too_large`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/payments/{id}/next_action": {
      "get": {
        "tags": [
          "Payments"
        ],
        "operationId": "getPaymentNextAction",
        "summary": "The current next_action for an in-flight payment (pull-on-demand)",
        "description": "Returns the payment's live next action, re-fetching a fresh one from the PSP when the stored action is missing or close to expiry (e.g. a DuitNow QR, which rotates about every minute). Poll this near expiry rather than caching the action.",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The current next action",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Payment not found (`not_found`), or the payment has no next action — e.g. it is terminal (`no_next_action`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "The PSP could not be reached to refresh the action. Code `psp_error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/balances": {
      "get": {
        "tags": [
          "Balances"
        ],
        "operationId": "getBalances",
        "summary": "Partner balances per currency (held / reserved / available)",
        "responses": {
          "200": {
            "description": "Balances",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "balances"
                  ],
                  "properties": {
                    "balances": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Balance"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/settlements": {
      "get": {
        "tags": [
          "Settlements"
        ],
        "operationId": "listSettlements",
        "summary": "List settlements (alias for payments with direction=payout)",
        "description": "Delegates to listPayments with direction forced to `payout`; it therefore accepts the same filters. A caller-supplied `direction` is ignored, so it is not declared.",
        "parameters": [
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "in": "query",
            "name": "cursor",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "state",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "currency",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "method",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/From"
          },
          {
            "$ref": "#/components/parameters/To"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of settlement payments",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ListPaymentsResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/disputes": {
      "get": {
        "tags": [
          "Disputes"
        ],
        "operationId": "listDisputes",
        "summary": "List disputes (chargebacks, returns, inquiries, retrievals)",
        "description": "Partner-scoped, read-only. **Bayar has no dispute ingestion path yet**: no PSP adapter reports chargebacks and no engine transition produces a dispute, so this endpoint returns an empty list for every partner today. An empty `data` array means \"Bayar has ingested no disputes\" — it does NOT mean \"no chargebacks occurred against your payments\". Do not present it to an end user as an authoritative chargeback record until ingestion ships.\n",
        "parameters": [
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "in": "query",
            "name": "cursor",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "state",
            "schema": {
              "type": "string",
              "enum": [
                "received",
                "evidence_required",
                "under_review",
                "won",
                "lost",
                "accepted",
                "expired"
              ]
            }
          },
          {
            "in": "query",
            "name": "kind",
            "schema": {
              "type": "string",
              "enum": [
                "chargeback",
                "return",
                "inquiry",
                "retrieval"
              ]
            }
          },
          {
            "in": "query",
            "name": "payment_id",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/From"
          },
          {
            "$ref": "#/components/parameters/To"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of disputes",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ListDisputesResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/disputes/{id}": {
      "get": {
        "tags": [
          "Disputes"
        ],
        "operationId": "getDispute",
        "summary": "Get a dispute, including its evidence blob",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The dispute",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Dispute"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/beneficiaries": {
      "get": {
        "tags": [
          "Beneficiaries"
        ],
        "operationId": "listBeneficiaries",
        "summary": "List beneficiaries",
        "responses": {
          "200": {
            "description": "Beneficiaries",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Beneficiary"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "Beneficiaries"
        ],
        "operationId": "createBeneficiary",
        "summary": "Create a beneficiary",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateBeneficiaryRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Beneficiary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/beneficiaries/{id}": {
      "put": {
        "tags": [
          "Beneficiaries"
        ],
        "operationId": "updateBeneficiary",
        "summary": "Update a beneficiary",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateBeneficiaryRequest"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Updated"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      },
      "delete": {
        "tags": [
          "Beneficiaries"
        ],
        "operationId": "deleteBeneficiary",
        "summary": "Delete a beneficiary",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    }
  },
  "components": {
    "parameters": {
      "From": {
        "in": "query",
        "name": "from",
        "description": "Inclusive lower bound on `created_at`. Two forms are accepted: an RFC3339 timestamp (`2026-07-01T09:30:00Z`), or a UTC calendar date (`2026-07-01`), read as the START of that UTC day. Any other value is a 400 — a malformed bound is never ignored, because a silently dropped filter would return the partner's entire history under the guise of a narrow window.",
        "schema": {
          "type": "string"
        },
        "example": "2026-07-01T00:00:00Z"
      },
      "To": {
        "in": "query",
        "name": "to",
        "description": "Inclusive upper bound on `created_at`. An RFC3339 timestamp, or a UTC calendar date read as the END of that UTC day (`2026-07-31` -> `23:59:59.999999Z`). Any other value is a 400.",
        "schema": {
          "type": "string"
        },
        "example": "2026-07-31"
      },
      "IdempotencyKey": {
        "in": "header",
        "name": "Idempotency-Key",
        "required": false,
        "description": "Caller-generated key that makes a mutation safely retryable. Every mutating operation honours it. Replaying the same key with the same body returns the first response verbatim; the same key with a DIFFERENT body is a 409 (`idempotency_conflict`). Keys expire after 24h. See ADR 0006.",
        "schema": {
          "type": "string"
        }
      }
    },
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "Partner API key, sent as `Authorization: Bearer <token>`."
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid request",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid credentials",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Not found",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Conflict": {
        "description": "Conflict",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Partner": {
        "type": "object",
        "description": "The account the calling API key belongs to. `GET /me` is the cheapest way to confirm a key works and to discover which partner it authenticates as.",
        "required": [
          "id",
          "display_name",
          "kind",
          "status",
          "settings"
        ],
        "properties": {
          "id": {
            "type": "string",
            "example": "prt_01H8ABCDEFGHJKMNPQRSTVWXYZ"
          },
          "display_name": {
            "type": "string",
            "example": "Acme Payments Sdn Bhd"
          },
          "kind": {
            "type": "string",
            "description": "Account classification. Platform-owned and read-only to partners — there is no integration endpoint that writes it.",
            "enum": [
              "partner",
              "platform"
            ],
            "example": "partner"
          },
          "status": {
            "type": "string",
            "description": "Account status. Platform-owned and read-only over this API. Note that `id`, `kind`, `status` and `display_name` are all deliberately excluded from the settings write path.",
            "enum": [
              "active",
              "suspended"
            ],
            "example": "active"
          },
          "settings": {
            "type": "object",
            "additionalProperties": true,
            "description": "Partner-configurable settings (e.g. tier, country, vertical). Free-form apart from the keys typed below: this is a partner-owned bag, not a fixed record.",
            "properties": {
              "primary_currency": {
                "type": "string",
                "example": "MYR",
                "description": "The currency code the partner chose to denominate single-number headlines in, set from the Partner Dashboard. OPTIONAL, and absent for any partner who never set one. Consumers MUST NOT substitute a default when it is missing — Bayar never converts between currencies, so an assumed code puts a denomination nobody chose on a real number. Render the absent case as unknown instead.\n\nDeliberately unconstrained on READ, unlike CurrencyCode on the write path: the column predates the write path (BAY-260) and can still hold whatever was put there by hand, so a client that trusts a pattern here will be wrong about a real row. Handle an unrecognized value as unrecognized; do not repair it."
              }
            }
          }
        }
      },
      "CurrencyCode": {
        "type": "string",
        "description": "A currency the platform can denominate in — the exact set the ledger carries, which is NOT the ISO-4217 set. USDT, USDC, BTC and ETH are here because Bayar's ledger genuinely holds them; validating against a bare `^[A-Z]{3}$` would reject a real denomination while accepting \"ZZZ\", which the platform cannot price, format, or settle.\n\nUppercase, exact. A lowercase code is rejected rather than coerced, so the column keeps one canonical spelling. Kept in sync with internal/money's scale table by a conformance test — adding a currency there without adding it here fails the build.",
        "enum": [
          "AUD",
          "BDT",
          "BHD",
          "BRL",
          "BTC",
          "CAD",
          "CDF",
          "CHF",
          "CNY",
          "EGP",
          "ETH",
          "EUR",
          "GBP",
          "GHS",
          "GMD",
          "GNF",
          "HKD",
          "IDR",
          "INR",
          "JPY",
          "KES",
          "KRW",
          "KWD",
          "MXN",
          "MYR",
          "MZN",
          "NGN",
          "NZD",
          "OMR",
          "PHP",
          "PKR",
          "RWF",
          "SGD",
          "SLE",
          "THB",
          "TZS",
          "UGX",
          "USD",
          "USDC",
          "USDT",
          "VND",
          "XAF",
          "XOF",
          "ZAR",
          "ZMW"
        ]
      },
      "Direction": {
        "type": "string",
        "description": "Which way the money moves. `collection` pulls funds FROM a payer INTO the partner's balance; `payout` pushes funds OUT to a beneficiary. It is fixed at creation and never changes.",
        "enum": [
          "collection",
          "payout"
        ],
        "example": "collection"
      },
      "Amount": {
        "type": "object",
        "description": "A sum of money: a decimal STRING plus its currency. Never a float — binary floating point cannot represent 0.10 exactly, and a payments ledger that rounds is a ledger that loses money. Parse it with a decimal type.",
        "required": [
          "amount",
          "currency"
        ],
        "properties": {
          "amount": {
            "type": "string",
            "description": "Decimal as string, in major units (\"150.00\", not 15000).",
            "example": "150.00"
          },
          "currency": {
            "type": "string",
            "description": "Currency code, uppercase. See CurrencyCode for the exact set.",
            "example": "MYR"
          }
        },
        "example": {
          "amount": "150.00",
          "currency": "MYR"
        }
      },
      "Payment": {
        "type": "object",
        "description": "One collection or payout. `presentment` is what the payer was asked for; `settlement` is what the partner is credited in. Bayar never applies an FX rate of its own, so for a same-currency payment the two agree.",
        "required": [
          "id",
          "state",
          "direction",
          "payment_method",
          "presentment",
          "settlement",
          "settled_total",
          "reversed_total",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Prefixed ULID. Stable, sortable by creation time.",
            "example": "pay_01H8ABCDEFGHJKMNPQRSTVWXYZ"
          },
          "merchant_reference": {
            "type": "string",
            "description": "The partner's own reference, echoed back verbatim. Bayar neither parses nor enforces uniqueness on it.",
            "example": "order-4821"
          },
          "state": {
            "type": "string",
            "description": "Position in the payment state machine. `succeeded`, `failed`, `cancelled`, `reversed` and `expired` are TERMINAL — a payment never leaves them, so a client may stop polling once it sees one. Treat an unrecognised value as non-terminal rather than as an error: states are added over time.",
            "enum": [
              "created",
              "routing",
              "authorizing",
              "authorized",
              "sending",
              "processing",
              "partial",
              "succeeded",
              "failed",
              "cancelled",
              "reversed",
              "expired"
            ],
            "example": "succeeded"
          },
          "direction": {
            "$ref": "#/components/schemas/Direction"
          },
          "payment_method": {
            "type": "string",
            "description": "The rail this payment runs on. Adapter-specific and open-ended — new methods appear as PSPs are onboarded, so do not switch exhaustively on it.",
            "example": "online_banking"
          },
          "presentment": {
            "$ref": "#/components/schemas/Amount"
          },
          "settlement": {
            "$ref": "#/components/schemas/Amount"
          },
          "settled_total": {
            "type": "string",
            "description": "How much has actually settled so far, in the SETTLEMENT currency. Equals `settlement.amount` on a clean success; less on a `partial`.",
            "example": "150.00"
          },
          "reversed_total": {
            "type": "string",
            "description": "How much has been reversed (refund or chargeback), in the settlement currency.",
            "example": "0.00"
          },
          "next_action": {
            "type": "object",
            "additionalProperties": true,
            "nullable": true,
            "description": "What the PAYER must do next, when the rail needs them to do something — follow a redirect, scan a QR. Null once no action is outstanding. The shape varies by `type`; read `type` first and treat unknown types as \"cannot be handled here\" rather than guessing.",
            "example": {
              "type": "redirect",
              "url": "https://psp.example/pay/6f2a1c"
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-07-14T09:31:22Z"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-07-14T09:33:05Z"
          }
        },
        "example": {
          "id": "pay_01H8ABCDEFGHJKMNPQRSTVWXYZ",
          "merchant_reference": "order-4821",
          "state": "succeeded",
          "direction": "collection",
          "payment_method": "online_banking",
          "presentment": {
            "amount": "150.00",
            "currency": "MYR"
          },
          "settlement": {
            "amount": "150.00",
            "currency": "MYR"
          },
          "settled_total": "150.00",
          "reversed_total": "0.00",
          "created_at": "2026-07-14T09:31:22Z",
          "updated_at": "2026-07-14T09:33:05Z"
        }
      },
      "ListPaymentsResponse": {
        "type": "object",
        "description": "One page of payments, newest first. Pass `next_cursor` back as `?cursor=` to fetch the following page; stop when it comes back empty. Do not construct cursors — they are opaque and their encoding is not contractual.",
        "required": [
          "data"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Payment"
            }
          },
          "next_cursor": {
            "type": "string",
            "description": "Empty when there are no more pages",
            "example": "eyJpZCI6InBheV8wMUg4QUJDIn0"
          }
        },
        "example": {
          "data": [
            {
              "id": "pay_01H8ABCDEFGHJKMNPQRSTVWXYZ",
              "merchant_reference": "order-4821",
              "state": "succeeded",
              "direction": "collection",
              "payment_method": "online_banking",
              "presentment": {
                "amount": "150.00",
                "currency": "MYR"
              },
              "settlement": {
                "amount": "150.00",
                "currency": "MYR"
              },
              "settled_total": "150.00",
              "reversed_total": "0.00",
              "created_at": "2026-07-14T09:31:22Z",
              "updated_at": "2026-07-14T09:33:05Z"
            },
            {
              "id": "pay_01H8BCDEFGHJKMNPQRSTVWXYZA",
              "merchant_reference": "payout-1190",
              "state": "processing",
              "direction": "payout",
              "payment_method": "bank_transfer",
              "presentment": {
                "amount": "2400.00",
                "currency": "MYR"
              },
              "settlement": {
                "amount": "2400.00",
                "currency": "MYR"
              },
              "settled_total": "0.00",
              "reversed_total": "0.00",
              "created_at": "2026-07-14T08:02:10Z",
              "updated_at": "2026-07-14T08:02:44Z"
            }
          ],
          "next_cursor": "eyJpZCI6InBheV8wMUg4QUJDIn0"
        }
      },
      "PaymentTimelineEntry": {
        "type": "object",
        "description": "One recorded state transition. Append-only — entries are never edited or removed.",
        "required": [
          "to_state",
          "reason",
          "actor_kind",
          "at"
        ],
        "properties": {
          "from_state": {
            "type": "string",
            "nullable": true,
            "description": "Null for the genesis row",
            "example": "authorizing"
          },
          "to_state": {
            "type": "string",
            "example": "authorized"
          },
          "reason": {
            "type": "string",
            "description": "Why the transition fired. A short machine-ish token, not prose.",
            "example": "psp_callback"
          },
          "actor_kind": {
            "type": "string",
            "description": "Who caused it — a dashboard `user`, an `api_key`, Bayar itself (`system`), or the `psp`.",
            "enum": [
              "user",
              "api_key",
              "system",
              "psp"
            ],
            "example": "psp"
          },
          "actor_id": {
            "type": "string",
            "nullable": true,
            "example": "psp_01H8MAXPAY0000000000000000"
          },
          "at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-07-14T09:32:41Z"
          }
        }
      },
      "PaymentTimelineResponse": {
        "type": "object",
        "description": "The payment's full transition history, oldest first. This is the audit trail — use it to answer \"how did it get here\", not to poll for the current state (`GET /payments/{id}` is cheaper for that).",
        "required": [
          "data"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PaymentTimelineEntry"
            }
          }
        },
        "example": {
          "data": [
            {
              "from_state": null,
              "to_state": "created",
              "reason": "api_create",
              "actor_kind": "api_key",
              "actor_id": "apk_01H8ABCDEFGHJKMNPQRSTVWXYZ",
              "at": "2026-07-14T09:31:22Z"
            },
            {
              "from_state": "created",
              "to_state": "authorizing",
              "reason": "route_selected",
              "actor_kind": "system",
              "actor_id": null,
              "at": "2026-07-14T09:31:23Z"
            },
            {
              "from_state": "authorizing",
              "to_state": "succeeded",
              "reason": "psp_callback",
              "actor_kind": "psp",
              "actor_id": "psp_01H8MAXPAY0000000000000000",
              "at": "2026-07-14T09:33:05Z"
            }
          ]
        }
      },
      "CreatePaymentRequest": {
        "type": "object",
        "description": "Send `Idempotency-Key` with this. Without one, a network timeout leaves you unable to tell \"never created\" from \"created, response lost\" — and retrying blind charges the payer twice.",
        "required": [
          "amount",
          "currency",
          "method",
          "direction"
        ],
        "properties": {
          "merchant_reference": {
            "type": "string",
            "description": "Your own reference for this payment. Echoed back on every read.",
            "example": "order-4821"
          },
          "amount": {
            "type": "string",
            "description": "Decimal string in major units. Must have no more decimal places than the currency allows (2 for MYR, 0 for JPY) — an over-precise amount is a 400, never a silent round.",
            "example": "150.00"
          },
          "currency": {
            "allOf": [
              {
                "$ref": "#/components/schemas/CurrencyCode"
              }
            ],
            "example": "MYR"
          },
          "method": {
            "type": "string",
            "description": "The rail to use. Which values are valid depends on the routed PSP and the country.",
            "example": "online_banking"
          },
          "direction": {
            "$ref": "#/components/schemas/Direction"
          },
          "country": {
            "type": "string",
            "description": "ISO-3166-1 alpha-2 country of the payer or beneficiary. Feeds routing.",
            "example": "MY"
          },
          "notification_url": {
            "type": "string",
            "description": "Per-payment webhook override. Leave unset to use the account's configured webhook endpoints, which is the normal case.",
            "example": "https://merchant.example/hooks/bayar"
          },
          "return_url": {
            "type": "string",
            "description": "Where to send the payer's browser after a redirect-based rail completes.",
            "example": "https://merchant.example/checkout/return"
          },
          "instrument": {
            "type": "object",
            "additionalProperties": true,
            "description": "Per-attempt payer details, shaped by `method` — a bank code for online banking, a beneficiary id for a payout. Consult the method's own docs.",
            "example": {
              "bank_code": "MBB0227",
              "payer_name": "Nurul A."
            }
          },
          "metadata": {
            "type": "object",
            "additionalProperties": true,
            "description": "Free-form key/value bag stored with the payment and returned on read. Never interpreted by Bayar. Do not put anything here that the payment engine needs to behave correctly.",
            "example": {
              "cart_id": "88213"
            }
          }
        },
        "example": {
          "merchant_reference": "order-4821",
          "amount": "150.00",
          "currency": "MYR",
          "method": "online_banking",
          "direction": "collection",
          "country": "MY",
          "return_url": "https://merchant.example/checkout/return"
        }
      },
      "Balance": {
        "type": "object",
        "description": "One currency's position. `available` is what can be paid out right now; `held` is the gross settled position; `reserved` is the part of it fenced off against pending payouts and disputes. `available = held - reserved`.\n\nThere is one row per currency the partner holds, and Bayar never converts between them — a MYR balance cannot fund a USD payout.",
        "required": [
          "currency",
          "held",
          "reserved",
          "available"
        ],
        "properties": {
          "currency": {
            "type": "string",
            "example": "MYR"
          },
          "held": {
            "type": "string",
            "description": "Gross settled position, decimal string.",
            "example": "48250.00"
          },
          "reserved": {
            "type": "string",
            "description": "Fenced off against pending payouts and disputes.",
            "example": "2400.00"
          },
          "available": {
            "type": "string",
            "description": "Free to pay out. `held` minus `reserved`.",
            "example": "45850.00"
          }
        },
        "example": {
          "currency": "MYR",
          "held": "48250.00",
          "reserved": "2400.00",
          "available": "45850.00"
        }
      },
      "Dispute": {
        "type": "object",
        "description": "A chargeback, return, inquiry or retrieval against one payment. `amount` and `currency` are the DISPUTED PAYMENT's presentment figures — the `disputes` table carries no amount column of its own, and Bayar never applies an FX rate.\n",
        "required": [
          "id",
          "payment_id",
          "psp_id",
          "kind",
          "state",
          "amount",
          "currency",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "example": "dsp_01H8ABCDEFGHJKMNPQRSTVWXYZ"
          },
          "payment_id": {
            "type": "string",
            "description": "The payment being disputed.",
            "example": "pay_01H8ABCDEFGHJKMNPQRSTVWXYZ"
          },
          "psp_id": {
            "type": "string",
            "example": "psp_01H8MAXPAY0000000000000000"
          },
          "operation_id": {
            "type": "string",
            "nullable": true,
            "example": null
          },
          "kind": {
            "type": "string",
            "enum": [
              "chargeback",
              "return",
              "inquiry",
              "retrieval"
            ],
            "example": "chargeback"
          },
          "state": {
            "type": "string",
            "description": "`won`, `lost`, `accepted` and `expired` are terminal. `evidence_required` is the one that needs you to act, by `due_at`.",
            "enum": [
              "received",
              "evidence_required",
              "under_review",
              "won",
              "lost",
              "accepted",
              "expired"
            ],
            "example": "evidence_required"
          },
          "due_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "When Bayar needs evidence by. Distinct from the PSP's own `evidence.deadline_at`.",
            "example": "2026-07-28T23:59:59Z"
          },
          "psp_dispute_ref": {
            "type": "string",
            "nullable": true,
            "example": "MP-CB-99213"
          },
          "resolved_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "example": null
          },
          "amount": {
            "type": "string",
            "description": "Decimal string; the disputed payment's presentment amount",
            "example": "150.00"
          },
          "currency": {
            "type": "string",
            "example": "MYR"
          },
          "evidence": {
            "allOf": [
              {
                "$ref": "#/components/schemas/DisputeEvidence"
              }
            ],
            "type": "object",
            "nullable": true,
            "description": "Returned only by GET /disputes/{id}; omitted from list responses."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "DisputeEvidence": {
        "type": "object",
        "description": "The v1 ALLOWLISTED projection of a dispute's evidence (BAY-233 §3, DR-1…DR-5). This is deliberately NOT the PSP's raw evidence payload.\n\nChargeback evidence from a card PSP routinely carries cardholder PII — name, PAN fragments, billing address, AVS/CVV results. Passing that through verbatim would drag Postgres, its backups, the BFF and the dashboard into PCI-DSS cardholder-data scope. Bayar holds no PAN and intends to keep it that way, so the response carries only these fields and the server drops every other key.\n\nAdding a field here is a security decision, not a schema chore: it must go through Security. Evidence DOCUMENTS are never returned — only a count and an opaque PSP-side reference to fetch them out of band.\n",
        "additionalProperties": false,
        "properties": {
          "reason_code": {
            "type": "string",
            "description": "PSP/scheme reason code, e.g. Visa \"4855\""
          },
          "reason_description": {
            "type": "string",
            "description": "Human-readable gloss of reason_code"
          },
          "psp_status": {
            "type": "string",
            "description": "The PSP's own dispute status string; NOT the Bayar `state`"
          },
          "psp_evidence_ref": {
            "type": "string",
            "description": "Opaque PSP-side handle for the evidence package — a reference, never the package"
          },
          "document_count": {
            "type": "integer",
            "description": "How many evidence documents the PSP holds. A count, not the content"
          },
          "submitted_at": {
            "type": "string",
            "format": "date-time",
            "description": "When evidence was submitted to the scheme"
          },
          "deadline_at": {
            "type": "string",
            "format": "date-time",
            "description": "The PSP's evidence deadline. Distinct from the dispute's own `due_at`"
          }
        }
      },
      "ListDisputesResponse": {
        "type": "object",
        "required": [
          "data"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Dispute"
            }
          },
          "next_cursor": {
            "type": "string"
          }
        }
      },
      "Beneficiary": {
        "type": "object",
        "description": "A stored payout destination. Reusable across payouts.",
        "required": [
          "id",
          "type",
          "account_ref",
          "details",
          "status"
        ],
        "properties": {
          "id": {
            "type": "string",
            "example": "ben_01H8ABCDEFGHJKMNPQRSTVWXYZ"
          },
          "type": {
            "type": "string",
            "description": "Which kind of destination this is. Determines what `details` must carry.",
            "enum": [
              "bank_account",
              "iban",
              "upi_vpa",
              "pix_key",
              "wallet",
              "card"
            ],
            "example": "bank_account"
          },
          "account_ref": {
            "type": "string",
            "description": "The destination identifier itself — account number, IBAN, VPA, PIX key.",
            "example": "157203004488"
          },
          "details": {
            "type": "object",
            "additionalProperties": true,
            "description": "Type-specific fields the rail needs — bank code and holder name for a `bank_account`, and so on. Required keys vary by `type`.",
            "example": {
              "bank_code": "MBBEMYKL",
              "account_holder": "Acme Payments Sdn Bhd"
            }
          },
          "currency": {
            "type": "string",
            "description": "Currency this destination can receive. Omitted when the rail is currency-agnostic.",
            "example": "MYR"
          },
          "status": {
            "type": "string",
            "description": "Closed set, enforced by a CHECK on the column. DELETE soft-deletes by setting `disabled` rather than removing the row.",
            "enum": [
              "active",
              "disabled"
            ],
            "example": "active"
          }
        },
        "example": {
          "id": "ben_01H8ABCDEFGHJKMNPQRSTVWXYZ",
          "type": "bank_account",
          "account_ref": "157203004488",
          "details": {
            "bank_code": "MBBEMYKL",
            "account_holder": "Acme Payments Sdn Bhd"
          },
          "currency": "MYR",
          "status": "active"
        }
      },
      "CreateBeneficiaryRequest": {
        "type": "object",
        "required": [
          "type",
          "account_ref"
        ],
        "properties": {
          "type": {
            "type": "string",
            "description": "The destination kind. Enforced by a CHECK on the column, not by the handler — so an unlisted value fails as a 500, not a 400. Send one of these.",
            "enum": [
              "bank_account",
              "iban",
              "upi_vpa",
              "pix_key",
              "wallet",
              "card"
            ],
            "example": "bank_account"
          },
          "account_ref": {
            "type": "string",
            "example": "157203004488"
          },
          "details": {
            "type": "object",
            "additionalProperties": true,
            "example": {
              "bank_code": "MBBEMYKL",
              "account_holder": "Acme Payments Sdn Bhd"
            }
          },
          "currency": {
            "type": "string",
            "example": "MYR"
          }
        },
        "example": {
          "type": "bank_account",
          "account_ref": "157203004488",
          "details": {
            "bank_code": "MBBEMYKL",
            "account_holder": "Acme Payments Sdn Bhd"
          },
          "currency": "MYR"
        }
      },
      "UpdateBeneficiaryRequest": {
        "type": "object",
        "description": "Partial update — omitted fields are left unchanged. `type` is immutable.",
        "properties": {
          "account_ref": {
            "type": "string",
            "example": "157203004499"
          },
          "details": {
            "type": "object",
            "additionalProperties": true,
            "example": {
              "bank_code": "MBBEMYKL",
              "account_holder": "Acme Payments Sdn Bhd"
            }
          },
          "currency": {
            "type": "string",
            "example": "MYR"
          },
          "status": {
            "type": "string",
            "description": "Enforced by the column's CHECK, not the handler — an unlisted value fails as a 500, not a 400.",
            "enum": [
              "active",
              "disabled"
            ],
            "example": "disabled"
          }
        },
        "example": {
          "status": "disabled"
        }
      },
      "Error": {
        "type": "object",
        "description": "The error envelope every non-2xx response uses. Branch on `error.code`, not on `error.message` — the code is contractual, the message is human-facing and may be reworded at any time.\n\nCodes in use today: `bad_request`, `validation_error`, `invalid_request`, `invalid_currency`, `insufficient_scope`, `not_found`, `no_next_action`, `conflict`, `idempotency_conflict`, `concurrent_modification`, `endpoint_disabled`, `in_progress`, `last_minting_key`, `not_failed`, `unknown_scope`, `no_route`, `export_too_large`, `rate_limited`, `psp_error`, `internal`. Treat an unrecognised code as a generic failure of its HTTP status class rather than as a client bug.",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable, machine-readable. Branch on this.",
                "example": "validation_error"
              },
              "message": {
                "type": "string",
                "description": "Human-readable. Not contractual — never parse it.",
                "example": "amount has more decimal places than MYR allows"
              }
            }
          }
        },
        "example": {
          "error": {
            "code": "validation_error",
            "message": "amount has more decimal places than MYR allows"
          }
        }
      }
    }
  }
}