openapi: 3.1.0
info:
  title: PayTaka Developer API
  # The document's own version (semver-style). It is NOT an SDK package
  # version and does not change with SDK releases; the API major version a
  # client targets is the `/v1` path prefix, recorded in x-api-version.
  version: "1.0.0"
  x-api-version: v1
  description: |
    The PayTaka public Developer API, version 1. It is the HOSTED-payment
    API: `/v1/payments` creates and reads hosted checkout Payments only.
    Manually recorded/offline payments are not part of API V1 and answer
    404. Documents only the external `/v1` contract, never the internal
    dashboard/Android API (`/api/v1`). See
    `backend/docs/developer-platform.md`.

    Authentication is a business-bound secret key
    (`Authorization: Bearer pt_live_sk_...`) — never an
    `X-Business-ID` header. The key alone determines which business a
    request operates on.

    **Never embed a secret key in a browser, an Android APK, an iOS app,
    a React Native bundle, or a Flutter bundle.** A secret key is a
    server-side credential only. Native and browser clients receive only
    a `checkout_url` from your own trusted backend and open it — no
    secret ever reaches the client.

    **Test mode.** A `pt_test_sk_...` key operates on a fully isolated
    sandbox: it creates `livemode: false` Payments that never touch real
    money, payment accounts, the Android Bridge, or Service Credit, and it
    can neither see nor modify `livemode: true` objects (and vice versa).
    A Test checkout can be completed with the `/test_helpers` endpoints.

    **Webhooks.** PayTaka can deliver signed, server-to-server events to
    an endpoint you register in the dashboard. See the `Event` and
    `WebhookPaymentEvent` schemas and the `paymentEvent` webhook below.
    Verify every delivery with the `PayTaka-Signature` header:
    `t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw request body>">` using your
    endpoint's `whsec_...` signing secret, and reject timestamps older
    than 5 minutes. Delivery is at-least-once — deduplicate on `event.id`.
servers:
  - url: "{apiBaseUrl}/v1"
    description: The PayTaka API. The production base URL is set per release (never assumed here).
    variables:
      apiBaseUrl:
        default: https://api.example.invalid
        description: The PayTaka API base URL provided for your release, for example the value of PAYTAKA_API_URL.
security:
  - bearerAuth: []
tags:
  - name: Payments
  - name: Test helpers
    description: TEST SECRET KEYS ONLY. A live key receives 404.
paths:
  /payments:
    post:
      operationId: createPayment
      x-required-scope: payments:write
      x-idempotency: recommended
      tags: [Payments]
      summary: Create a hosted Payment
      description: |
        Creates a PENDING, automatically-verifiable hosted Payment and
        returns its `checkout_url`. Only a UX-level check runs at
        creation (an enabled, Bridge-ready receiving method and enough
        Service Credit for the next fee) — the real fee reservation
        happens when the customer reaches checkout.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePaymentRequest'
            examples:
              minimal:
                summary: Minimal request
                value:
                  amount: "500.00"
              full:
                summary: Full request
                value:
                  amount: "500.00"
                  description: "Order #123"
                  merchant_reference: "ORDER-123"
                  external_id: "my-order-123"
                  return_url: "https://merchant.example/payment-return"
                  metadata:
                    order_id: "123"
                  allowed_methods: ["BKASH", "NAGAD"]
                  allowed_channels: ["SEND_MONEY", "PAYMENT", "CASH_IN", "BANK_TRANSFER"]
      responses:
        "201":
          description: Payment created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payment'
              example:
                id: "5f2c9e0a-6b1e-4b8a-9b3b-2e6a2b6c9a11"
                object: "payment"
                livemode: true
                status: "PENDING"
                amount: "500.00"
                currency: "BDT"
                description: "Order #123"
                merchant_reference: "ORDER-123"
                external_id: "my-order-123"
                metadata:
                  order_id: "123"
                allowed_methods: ["BKASH", "NAGAD"]
                allowed_channels: ["SEND_MONEY", "PAYMENT"]
                return_url: "https://merchant.example/payment-return"
                checkout_url: "https://checkout.example.test/pay_AbCdEf123456"
                created_at: "2026-09-20T10:15:00Z"
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "409":
          $ref: '#/components/responses/Conflict'
        "429":
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        - lang: curl
          source: |
            curl $PAYTAKA_API_URL/v1/payments \
              -X POST \
              -H "Authorization: Bearer $PAYTAKA_SECRET_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: order-123-payment" \
              -d '{
                "amount": "500.00",
                "external_id": "order-123",
                "return_url": "https://example.com/payment-return"
              }'
    get:
      operationId: listPayments
      x-required-scope: payments:read
      x-pagination: cursor
      tags: [Payments]
      summary: List Payments
      description: Cursor-paginated. `payments:read` scope required.
      security:
        - bearerAuth: []
      parameters:
        - name: status
          in: query
          schema:
            $ref: '#/components/schemas/PaymentStatus'
        - name: external_id
          in: query
          schema:
            type: string
        - name: created_from
          in: query
          schema:
            type: string
            format: date-time
        - name: created_to
          in: query
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: starting_after
          in: query
          description: A Payment id — returns results immediately after it.
          schema:
            type: string
      responses:
        "200":
          description: A page of Payments
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentList'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /payments/{id}:
    get:
      operationId: getPayment
      x-required-scope: payments:read
      tags: [Payments]
      summary: Retrieve a Payment
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/PaymentId'
      responses:
        "200":
          description: The Payment
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payment'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /payments/{id}/cancel:
    post:
      operationId: cancelPayment
      x-required-scope: payments:write
      tags: [Payments]
      summary: Cancel a pending Payment
      description: |
        Only valid while the Payment is PENDING. Cancels any active
        checkout attempt and releases its Service Credit fee reservation
        atomically. `payments:write` scope required.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/PaymentId'
      responses:
        "200":
          description: The cancelled Payment
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payment'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          $ref: '#/components/responses/Conflict'
        "429":
          $ref: '#/components/responses/RateLimited'
  /test_helpers/payments/{id}/succeed:
    post:
      operationId: simulatePaymentSucceeded
      x-required-scope: payments:write
      x-test-mode-only: true
      tags: [Test helpers]
      summary: Simulate a successful payment (TEST SECRET KEYS ONLY)
      description: |
        **TEST SECRET KEYS ONLY.** Moves a PENDING `livemode: false`
        Payment to PAID with no real money, SMS, Bridge, or Service
        Credit involved, and emits `payment.paid` to Test webhook
        endpoints. A `pt_live_sk_...` key receives 404, exactly as if
        this route did not exist. `payments:write` scope required.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/PaymentId'
      responses:
        "200":
          description: The PAID Payment
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payment'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          $ref: '#/components/responses/Conflict'
        "429":
          $ref: '#/components/responses/RateLimited'
  /test_helpers/payments/{id}/cancel:
    post:
      operationId: simulatePaymentCancelled
      x-required-scope: payments:write
      x-test-mode-only: true
      tags: [Test helpers]
      summary: Simulate a cancelled payment (TEST SECRET KEYS ONLY)
      description: |
        **TEST SECRET KEYS ONLY.** Moves a PENDING `livemode: false`
        Payment to CANCELLED and emits `payment.cancelled` to Test
        webhook endpoints. A `pt_live_sk_...` key receives 404.
        `payments:write` scope required.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/PaymentId'
      responses:
        "200":
          description: The CANCELLED Payment
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payment'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          $ref: '#/components/responses/Conflict'
        "429":
          $ref: '#/components/responses/RateLimited'
webhooks:
  paymentEvent:
    post:
      operationId: receivePaymentEvent
      tags: [Payments]
      summary: Payment event delivered to your webhook endpoint
      description: |
        PayTaka POSTs this request to each enabled endpoint subscribed to
        the event type, in the same environment (a Test event never
        reaches a Live endpoint). Respond with any 2xx to acknowledge;
        3xx/4xx/5xx and timeouts (10s) are retried on a backoff schedule.
        Redirects are never followed. Headers: `PayTaka-Event-ID`,
        `PayTaka-Signature`, `User-Agent: PayTaka-Webhooks/1.0`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPaymentEvent'
      responses:
        "2XX":
          description: Acknowledged
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: pt_live_sk_... / pt_test_sk_...
      description: |
        A business-bound secret key. Server-side only — never embed this
        in a browser, Android, iOS, React Native, or Flutter client.
  parameters:
    PaymentId:
      name: id
      in: path
      required: true
      schema:
        type: string
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        A merchant-generated key. The same key + an identical request
        body returns the original result without creating a duplicate
        Payment. The same key with a different body is a 409
        idempotency_conflict. Retained 24 hours.
      schema:
        type: string
  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing, invalid, or revoked API key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: invalid_api_key
              message: The API key is invalid or has been revoked.
              request_id: req_3f9e...
    Forbidden:
      description: The key does not have the required scope
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Not found (including a Payment belonging to a different business)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: State/idempotency/external_id conflict
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: insufficient_credit
              message: The business does not have enough service credit to create this payment.
              request_id: req_3f9e...
    RateLimited:
      description: Too many requests
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
  schemas:
    PaymentStatus:
      type: string
      description: |
        Only statuses a Developer API consumer can actually observe.
        REVERSED reflects PayTaka's own financial record only — it does
        not imply a provider-side fund transfer back to the customer.
        Additive-evolution policy: new statuses may be added in a later
        release of API v1, so clients must tolerate an unknown value when
        reading a response (request filters stay strict).
      enum: [PENDING, PAID, CANCELLED, REVERSED]
    PaymentMethod:
      type: string
      description: |
        Provider codes. New providers may be added, so clients must tolerate
        an unknown value when reading a response.
      enum: [BKASH, NAGAD]
    CollectionChannel:
      type: string
      description: |
        How the customer pays. SEND_MONEY = Send Money, PAYMENT = Payment,
        CASH_IN = Cash In, BANK_TRANSFER = Bank / Card Add Money. Clients
        must tolerate values added in the future when READING a response.
      enum: [SEND_MONEY, PAYMENT, CASH_IN, BANK_TRANSFER]
    Metadata:
      type: object
      description: |
        Bounded, string-only, developer-supplied context. Never shown to
        the customer, never used for financial matching. At most 20
        keys; each key at most 64 characters; each value at most 500
        characters.
      additionalProperties:
        type: string
    CreatePaymentRequest:
      type: object
      required: [amount]
      properties:
        amount:
          type: string
          description: Decimal string in BDT major units, e.g. "500.00". Never a JSON number.
          example: "500.00"
        description:
          type: string
        merchant_reference:
          type: string
          description: Human/business reference — distinct from external_id.
        external_id:
          type: string
          description: Your own correlation identifier. Unique per business (per livemode) when set.
        return_url:
          type: string
          description: |
            Where hosted checkout offers to return the customer after a
            terminal state. An absolute https URL or a native-app deep
            link scheme (myapp://..., com.example.app://...).
            javascript:/data:/file:/intent: are always rejected. Never
            authoritative proof of payment — always confirm status via
            this API or a webhook.
        metadata:
          $ref: '#/components/schemas/Metadata'
        allowed_methods:
          type: array
          items: { $ref: '#/components/schemas/PaymentMethod' }
        allowed_channels:
          type: array
          items: { $ref: '#/components/schemas/CollectionChannel' }
        customer_id:
          type: string
        order_id:
          type: string
    Payment:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [payment] }
        livemode: { type: boolean }
        status: { $ref: '#/components/schemas/PaymentStatus' }
        amount: { type: string }
        currency: { type: string, enum: [BDT] }
        description:
          type: [string, "null"]
        merchant_reference:
          type: [string, "null"]
        external_id:
          type: [string, "null"]
        metadata: { $ref: '#/components/schemas/Metadata' }
        allowed_methods:
          type: array
          items: { $ref: '#/components/schemas/PaymentMethod' }
        allowed_channels:
          type: array
          items: { $ref: '#/components/schemas/CollectionChannel' }
        return_url:
          type: [string, "null"]
        checkout_url:
          type: [string, "null"]
        created_at: { type: string, format: date-time }
        paid_at:
          type: [string, "null"]
          format: date-time
        cancelled_at:
          type: [string, "null"]
          format: date-time
      required: [id, object, livemode, status, amount, currency, created_at]
    PaymentList:
      type: object
      properties:
        object: { type: string, enum: [list] }
        data:
          type: array
          items: { $ref: '#/components/schemas/Payment' }
        has_more: { type: boolean }
        next_cursor:
          type: [string, "null"]
      required: [object, data, has_more]
    WebhookEventType:
      type: string
      description: Only events for transitions that actually exist. `endpoint.test` is a synthetic, non-financial event sent from the dashboard to Test endpoints.
      enum: [payment.created, payment.paid, payment.cancelled, payment.reversed, endpoint.test]
    Event:
      type: object
      description: |
        The stable webhook envelope. `id` is generated once per logical
        event and reused on every retry — your idempotency key.
        `data.object` is exactly the Payment returned by
        `GET /v1/payments/{id}` at the moment of the event; the payload is
        snapshotted at creation and never regenerated for retries.
      properties:
        id: { type: string, description: "evt_...", example: evt_2q8Zc0mRk1nT }
        object: { type: string, enum: [event] }
        api_version: { type: string, enum: [v1] }
        type: { $ref: '#/components/schemas/WebhookEventType' }
        livemode: { type: boolean }
        created_at: { type: string, format: date-time }
        data:
          type: object
          properties:
            object: { type: object }
          required: [object]
      required: [id, object, api_version, type, livemode, created_at, data]
    WebhookPaymentEvent:
      description: A `payment.*` event — `data.object` is a Payment.
      allOf:
        - $ref: '#/components/schemas/Event'
        - type: object
          properties:
            type:
              type: string
              enum: [payment.created, payment.paid, payment.cancelled, payment.reversed]
            data:
              type: object
              properties:
                object: { $ref: '#/components/schemas/Payment' }
              required: [object]
    Error:
      type: object
      description: |
        Every error response has this shape. `code` is a stable,
        machine-readable string — clients must treat it as an open string
        (new codes may be added). Always quote `request_id` to support.
      x-error-codes:
        - code: invalid_api_key
          status: 401
          description: "The API key is missing, malformed, unknown, or revoked."
        - code: insufficient_scope
          status: 403
          description: "The key lacks the scope (payments:read or payments:write) this endpoint needs."
        - code: rate_limit_exceeded
          status: 429
          description: "Too many requests for this key. Honor the Retry-After header."
        - code: invalid_request
          status: 400
          description: "The request body or a parameter is malformed."
        - code: invalid_amount
          status: 400
          description: "amount must be a positive decimal string with at most two decimals, e.g. \"500.00\"."
        - code: invalid_external_id
          status: 400
          description: "external_id is invalid or too long."
        - code: invalid_metadata
          status: 400
          description: "metadata exceeds its limits (20 keys, 64-char keys, 500-char values)."
        - code: invalid_return_url
          status: 400
          description: "return_url must be an absolute https URL or a native app deep link."
        - code: invalid_allowed_method
          status: 400
          description: "allowed_methods must only name BKASH or NAGAD."
        - code: invalid_allowed_channel
          status: 400
          description: "allowed_channels must only name SEND_MONEY, PAYMENT, CASH_IN or BANK_TRANSFER."
        - code: description_too_long
          status: 400
          description: "description is too long."
        - code: reference_too_long
          status: 400
          description: "merchant_reference is too long."
        - code: invalid_customer
          status: 400
          description: "customer_id does not belong to your business."
        - code: order_not_found
          status: 400
          description: "order_id does not exist for your business."
        - code: order_customer_mismatch
          status: 400
          description: "The payment's customer does not match the order's customer."
        - code: exceeds_order_due
          status: 400
          description: "amount exceeds the order's remaining due."
        - code: context_items_not_accepted
          status: 400
          description: "context_items are not accepted by the Developer API."
        - code: payment_not_found
          status: 404
          description: "No hosted payment with this id exists for this key's business and environment."
        - code: not_found
          status: 404
          description: "No such API route."
        - code: method_not_allowed
          status: 405
          description: "The HTTP method is not allowed for this route."
        - code: external_id_conflict
          status: 409
          description: "A payment with this external_id already exists (per business and environment)."
        - code: idempotency_conflict
          status: 409
          description: "This Idempotency-Key was already used with a different request."
        - code: idempotency_in_progress
          status: 409
          description: "A request with this Idempotency-Key is still being processed; retry shortly."
        - code: invalid_transition
          status: 409
          description: "The payment is not in a state that allows this action (for example, cancelling a paid payment)."
        - code: order_cancelled
          status: 409
          description: "The referenced order is cancelled."
        - code: payment_method_unavailable
          status: 409
          description: "None of the allowed payment methods are currently usable (live mode)."
        - code: payment_channel_unavailable
          status: 409
          description: "No ready receiving number accepts the requested payment type (live mode)."
        - code: payment_detection_not_ready
          status: 409
          description: "The business has no enabled, ready receiving method yet (live mode)."
        - code: insufficient_credit
          status: 409
          description: "The business does not have enough Service Credit for the next fee (live mode)."
        - code: internal_error
          status: 500
          description: "Something went wrong on PayTaka's side. Retry; quote request_id to support if it persists."
      properties:
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }
            param: { type: string }
            request_id: { type: string }
          required: [code, message, request_id]
      required: [error]
