openapi: 3.0.3
info:
  title: PAYDER Merchant API
  version: "1.0.0"
  description: |
    Take payments, refund them and pay out sellers.

    **Environments** — two, fully separate (own merchants, keys, webhook secrets):
    * Sandbox: `https://sandbox-api.payder.ng` — keys start `pdr_test_sk_`, no real money, no KYC.
    * Live: `https://api.payder.ng` — keys start `pdr_live_sk_`, requires approved KYC.
    A key only works on its own environment (otherwise `403 wrong_environment`).

    **Amounts** are integers in minor units (kobo), currency `"NGN"`.

    **Every request** sends `Authorization: Bearer <api_key>` and `X-Merchant-Id: <merchant_id>`.
    Write requests (POST) also send `Idempotency-Key: <unique string>`:
    same key + same body replays the original response (header `Idempotent-Replayed: true`);
    same key + different body → `409 idempotency_key_reused`.

    **Errors** are always `{ "error": { "code": "...", "message": "..." } }`.
    Rate limit: 120 requests/minute per API key (`429 rate_limited`, `Retry-After` header).

    **Webhooks** — see the `webhooks` section at the bottom.
servers:
  - url: https://sandbox-api.payder.ng
    description: Sandbox
  - url: https://api.payder.ng
    description: Live
security:
  - bearerAuth: []
    merchantId: []
paths:
  /v1/checkouts:
    post:
      summary: Create a checkout
      description: Returns a hosted `checkoutUrl`. Send the buyer there; after paying (card or bank transfer) they are redirected to `returnUrl?reference=…&status=…`. **Never trust the redirect** — confirm with the webhook or `GET /v1/checkouts/{reference}`.
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateCheckout' }
            example:
              reference: ORD-10045
              amount: 1250000
              currency: NGN
              description: Order Q-10045
              customer: { email: buyer@example.com }
              returnUrl: https://quadbay.example/checkout/return
              metadata: { orderCode: Q-10045 }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckoutCreated' }
              example: { id: chk_9f2c…, reference: ORD-10045, checkoutUrl: 'https://www.payder.ng/pay/chk_9f2c…', status: pending }
        '409': { $ref: '#/components/responses/Conflict' }
        '400': { $ref: '#/components/responses/BadRequest' }
  /v1/checkouts/{reference}:
    get:
      summary: Get a checkout
      parameters: [{ $ref: '#/components/parameters/Reference' }]
      responses:
        '200':
          description: Current status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Checkout' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/refunds:
    post:
      summary: Refund a payment (full or partial)
      description: "`amount` plus earlier non-failed refunds can never exceed the amount paid (`422 refund_exceeds_paid`). The payment must be `succeeded` (`409 payment_not_paid`)."
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateRefund' }
            example: { reference: RF-10045-1, paymentReference: ORD-10045, amount: 250000, reason: Item returned }
      responses:
        '201':
          description: Created (status `pending`, then `refund.succeeded` / `refund.failed` webhook)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ObjectRef' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
  /v1/refunds/{reference}:
    get:
      summary: Get a refund
      parameters: [{ $ref: '#/components/parameters/Reference' }]
      responses:
        '200': { description: Current status, content: { application/json: { schema: { $ref: '#/components/schemas/Refund' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/payouts:
    post:
      summary: Pay out to a seller's Nigerian bank account
      description: "Live payouts are paid from the merchant's PAYDER balance (paid-in minus refunds and payouts) — `422 insufficient_balance` otherwise. `recipient.accountNumber` must be a 10-digit NUBAN. Sandbox: account `0123456789` succeeds, `0000000000` fails, anything else succeeds; both settle ~5 seconds after creation."
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreatePayout' }
            example:
              reference: PO-77
              amount: 5000000
              currency: NGN
              recipient: { bankName: Access Bank, accountNumber: '0123456789', accountName: Seller One }
              narration: Quadbay payout
      responses:
        '201':
          description: Created (status `pending`, then `payout.succeeded` / `payout.failed` webhook)
          content: { application/json: { schema: { $ref: '#/components/schemas/ObjectRef' } } }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
  /v1/payouts/{reference}:
    get:
      summary: Get a payout
      parameters: [{ $ref: '#/components/parameters/Reference' }]
      responses:
        '200': { description: Current status, content: { application/json: { schema: { $ref: '#/components/schemas/Payout' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/escrows:
    post:
      summary: Hold a buyer's money until conditions are met (seller must have a PAYDER wallet)
      responses:
        '201': { description: Created, returns payUrl }
  /v1/escrows/{reference}:
    get:
      summary: Get an escrow
      parameters: [{ $ref: '#/components/parameters/Reference' }]
      responses:
        '200': { description: Escrow }
  /v1/escrows/{reference}/release:
    post:
      summary: Conditions met, pay the seller
      parameters: [{ $ref: '#/components/parameters/Reference' }]
      responses:
        '200': { description: Escrow }
  /v1/escrows/{reference}/refund:
    post:
      summary: Refund the buyer, fully or partly
      parameters: [{ $ref: '#/components/parameters/Reference' }]
      responses:
        '200': { description: Escrow }
  /v1/escrows/{reference}/cancel:
    post:
      summary: Cancel an unpaid escrow
      parameters: [{ $ref: '#/components/parameters/Reference' }]
      responses:
        '200': { description: Escrow }
  /v1/accounts/{walletId}:
    get:
      summary: Check that a PAYDER wallet exists (masked name)
      responses:
        '200': { description: Account }
  /v1/banks:
    get:
      summary: List Nigerian banks and their codes (use `code` as recipient.bankCode on payouts)
      responses:
        '200': { description: Banks }
  /v1/balance:
    get:
      summary: Amount available to pay out, in kobo (paid in minus refunds and payouts)
      responses:
        '200': { description: Balance }
components:
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer, description: "API key: pdr_test_sk_… or pdr_live_sk_…" }
    merchantId: { type: apiKey, in: header, name: X-Merchant-Id, description: "Your Merchant ID (mch_test_… / mch_live_…)" }
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, maxLength: 255 }
    Reference:
      name: reference
      in: path
      required: true
      schema: { type: string }
  responses:
    BadRequest: { description: Invalid request, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    NotFound: { description: Not found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    Conflict: { description: "duplicate_reference | idempotency_key_reused | request_in_progress | payment_not_paid", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    Unprocessable: { description: "refund_exceeds_paid | insufficient_balance | unsupported_bank", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string, example: duplicate_reference }
            message: { type: string }
    CreateCheckout:
      type: object
      required: [reference, amount, currency, customer, returnUrl]
      properties:
        reference: { type: string, maxLength: 100, description: "Your unique reference. Letters, digits and . _ : - only." }
        amount: { type: integer, minimum: 100, description: "Kobo. Minimum 100 (₦1)." }
        currency: { type: string, enum: [NGN] }
        description: { type: string, maxLength: 255 }
        customer: { type: object, required: [email], properties: { email: { type: string, format: email } } }
        returnUrl: { type: string, format: uri, description: "https required in live." }
        metadata: { type: object, description: "Up to 4 KB, returned by GET." }
    CheckoutCreated:
      type: object
      properties:
        id: { type: string }
        reference: { type: string }
        checkoutUrl: { type: string }
        status: { type: string, enum: [pending] }
    Checkout:
      type: object
      properties:
        id: { type: string }
        reference: { type: string }
        status: { type: string, enum: [pending, succeeded, failed] }
        amount: { type: integer }
        currency: { type: string }
        description: { type: string, nullable: true }
        customer: { type: object, properties: { email: { type: string } } }
        metadata: { type: object, nullable: true }
        checkoutUrl: { type: string }
        paymentMethod: { type: string, nullable: true }
        failureReason: { type: string, nullable: true }
        refundedAmount: { type: integer }
        paidAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
    CreateRefund:
      type: object
      required: [reference, paymentReference, amount]
      properties:
        reference: { type: string }
        paymentReference: { type: string, description: "The checkout's reference." }
        amount: { type: integer, minimum: 1 }
        reason: { type: string, maxLength: 255, description: "Sandbox only: the exact reason `sandbox_fail` forces a failed refund." }
    Refund:
      allOf:
        - { $ref: '#/components/schemas/ObjectRef' }
        - type: object
          properties:
            paymentReference: { type: string }
            amount: { type: integer }
            currency: { type: string }
            reason: { type: string, nullable: true }
            failureReason: { type: string, nullable: true }
            createdAt: { type: string, format: date-time }
    CreatePayout:
      type: object
      required: [reference, amount, currency, recipient]
      properties:
        reference: { type: string }
        amount: { type: integer, minimum: 10000, description: "Kobo. Minimum 10,000 (₦100)." }
        currency: { type: string, enum: [NGN] }
        recipient:
          type: object
          required: [bankName, accountNumber, accountName]
          properties:
            bankName: { type: string }
            accountNumber: { type: string, pattern: '^[0-9]{10}$' }
            accountName: { type: string }
        narration: { type: string, maxLength: 100 }
    Payout:
      allOf:
        - { $ref: '#/components/schemas/ObjectRef' }
        - type: object
          properties:
            amount: { type: integer }
            currency: { type: string }
            recipient: { type: object }
            narration: { type: string, nullable: true }
            failureReason: { type: string, nullable: true }
            createdAt: { type: string, format: date-time }
    ObjectRef:
      type: object
      properties:
        id: { type: string }
        reference: { type: string }
        status: { type: string, enum: [pending, succeeded, failed] }
    WebhookPayload:
      type: object
      description: |
        POSTed to your webhook URL (e.g. `https://<your-api>/api/webhooks/payder`).
        Header `x-payder-signature` = lowercase hex HMAC-SHA256 of the **raw request body** keyed with your webhook secret.
        Respond 2xx. Non-2xx / timeouts are retried after 1m, 5m, 30m, 2h, then every 12h until 24h after the event. Deliveries can repeat — handle them idempotently by `event` + `reference`.
        Events: payment.succeeded, payment.failed, refund.succeeded, refund.failed, payout.succeeded, payout.failed.
      properties:
        event: { type: string }
        reference: { type: string, description: "Your reference for the checkout / refund / payout." }
        amountMinor: { type: integer }
        currency: { type: string }
        occurredAt: { type: string, format: date-time }
