openapi: 3.0.3

# The source of truth for this API.
#
# Import it straight into Postman (Import -> File -> openapi.yaml), Insomnia,
# Bruno or anything else that reads OpenAPI, and you get a working collection
# with every field, example and error already filled in. The rendered page at
# /docs is this file; nothing is written twice.
#
# 3.0.3 rather than 3.1 on purpose: Postman's importer is still better at 3.0,
# and nothing here needs anything 3.1 added.

info:
  title: Takpayment Merchant API
  version: "1.0.0"
  description: |
    Create a Shaparak payment link from your own backend, send your customer to
    it, and verify the result when they come back.

    You never handle card data. The card is entered on our payment page, which
    is served from inside Iran, and your customer is never dropped on a bank
    page they have to find their way back from.

    ## The flow

    1. Your backend calls `POST /v1/payments` and gets back a `url`.
    2. You send your customer to that `url`.
    3. They pay. We send them back to your `callback_url` with `?uuid=…&status=…`.
    4. Your backend calls `POST /v1/transactions/verifyTransaction`.
    5. If `is_verified` is true **and** `is_checked` is false — credit them.

    ## Your balance moves when you verify, not before

    **A payment does not reach your Takpayment wallet until your backend calls
    `verifyTransaction`.** The money is taken from the card and held against the
    payment; your balance, and what you can withdraw, only changes on that call.

    So a paid transaction you never verify is a balance you never receive. If
    your dashboard shows a payment as paid but your balance has not moved, the
    verify call has not happened — that is the thing to check first.

    This is deliberate: our ledger follows your acknowledgement rather than
    running ahead of it, so the two sides cannot disagree about which payments
    you have accepted.

    ## Conventions

    - Amounts are **Iranian Rial**, integers, no decimals.
    - Times are UTC, ISO-8601.
    - Every response carries the same envelope: `statusCode`, `data`,
      `timestamp`. Errors add `error` and `message` and set `data` to null.
    - Switch on `error`, never on `message`. The codes are stable; the wording
      is not.

  contact:
    name: Takpayment support

servers:
  - url: https://api.takpayment.com
    description: Production

security:
  - ApiToken: []

tags:
  - name: Payments
    description: Creating a payment link and reading its state.
  - name: Transactions
    description: Confirming that money arrived, exactly once.
  # Rendered last, deliberately: it is the one thing to read twice, and it
  # belongs after the endpoints it talks about rather than in front of them.
  - name: is_verified and is_checked
    description: |
      Two flags on `verifyTransaction`, asking different questions.

      | | asks | answer |
      |---|---|---|
      | `is_verified` | did the money arrive? | the same every time you ask |
      | `is_checked` | had you already been told? | `false` **exactly once** |

      ```
      if (is_verified && !is_checked) credit();
      ```

      `is_checked` is `false` on the first call that finds the payment paid, and
      `true` on every call after. On any retry — a timeout, a refreshed callback
      page, a double-submitted form — it comes back `true`, and that is how you
      know not to credit a second time.

      The flag is claimed inside a single database update, so two requests
      arriving at the same moment cannot both be first. One of them gets the
      `false`.

      An **unpaid** payment is left unclaimed, so you can keep asking while your
      customer is still on the bank's page without using up the one `false` you
      get. `GET /v1/payments/{uuid}` does not claim it either — reading a
      payment and acting on it are separate things.

      Asking twice is safe and expected. Crediting twice is not, and this is
      what stops it.

      ### It also moves your balance

      The first successful `verifyTransaction` is what credits the payment to
      your Takpayment wallet. Before it, the money is held against the payment
      and is not part of your balance or available to withdraw.

      A paid transaction you never verify is a balance you never receive.

paths:

  /v1/payments:
    post:
      tags: [Payments]
      summary: Create a payment link
      operationId: createPayment
      description: |
        Returns a `url` to send your customer to. The link expires in 13
        minutes, so create it when they are ready to pay.

        Send the same `order_id` again and you get the same payment back
        instead of a new one, so retrying a request that timed out is safe.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePaymentRequest'
            examples:
              minimal:
                summary: The least you can send
                value:
                  amount: 500000
              typical:
                summary: A deposit on a shop
                value:
                  amount: 500000
                  order_id: deposit-8821
                  callback_url: https://shop.example/payment/return
                  description: Wallet top-up
              ipLocked:
                summary: Locked to the customer's address
                value:
                  amount: 500000
                  order_id: deposit-8822
                  callback_url: https://shop.example/payment/return
                  user_ip: 5.23.112.40
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        $ref: '#/components/schemas/Payment'
              example:
                statusCode: 201
                data:
                  uuid: 56326e0f-8747-4edc-9fb5-cf8b68862e93
                  is_verified: false
                  is_checked: false
                  order_id: deposit-8821
                  status: pending
                  amount: 500000
                  currency: IRR
                  url: https://varizi.site/l/bGSR4wkHM4Y2fHtK0F7bvBVaCMMThLz0W
                  ip_locked: null
                  description: Wallet top-up
                  created_at: "2026-10-12T11:26:01+00:00"
                  expires_at: "2026-10-12T11:39:01+00:00"
                  paid_at: null
                  trace: null
                  card: null
                timestamp: "2026-10-12T11:26:01+00:00"
        '200':
          description: |
            This `order_id` already exists, so you get the payment you created
            before, unchanged, with `idempotent: true`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        allOf:
                          - $ref: '#/components/schemas/Payment'
                          - properties:
                              idempotent:
                                type: boolean
                                example: true
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/IpNotAllowed' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/{uuid}:
    get:
      tags: [Payments]
      summary: Read a payment
      operationId: getPayment
      description: |
        Read-only status, including `is_verified` and `is_checked`.

        Reading **never** changes `is_checked` and never credits your wallet —
        poll this as often as you like. Only `verifyTransaction` does either.
      parameters:
        - name: uuid
          in: path
          required: true
          schema: { type: string, format: uuid }
          example: 56326e0f-8747-4edc-9fb5-cf8b68862e93
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        $ref: '#/components/schemas/Payment'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/transactions/verifyTransaction:
    post:
      tags: [Transactions]
      summary: Verify a transaction
      operationId: verifyTransaction
      description: |
        The call you credit from. Identify the payment by `uuid` — what the
        redirect gives you — or by your own `order_id`.

        ```
        if (is_verified && !is_checked) credit();
        ```

        **This call is also what moves the money into your Takpayment wallet.**
        Until it succeeds the payment is held, not credited, and cannot be
        withdrawn.

        See **is_verified and is_checked** at the end of this document for what
        the two flags mean.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VerifyRequest'
            examples:
              byUuid:
                summary: By the id from the redirect
                value: { uuid: 56326e0f-8747-4edc-9fb5-cf8b68862e93 }
              byOrderId:
                summary: By your own reference
                value: { order_id: deposit-8821 }
      responses:
        '200':
          description: |
            The transaction's state. **200 does not mean paid** — read
            `is_verified`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        $ref: '#/components/schemas/Verification'
              examples:
                firstTime:
                  summary: Paid, first time you have asked — credit now
                  value:
                    statusCode: 200
                    data:
                      is_verified: true
                      is_checked: false
                      uuid: 56326e0f-8747-4edc-9fb5-cf8b68862e93
                      order_id: deposit-8821
                      status: paid
                      amount: 500000
                      currency: IRR
                      trace: "23213819"
                      card: 603799******4467
                      paid_at: "2026-10-12T11:27:18+00:00"
                    timestamp: "2026-10-12T11:27:20+00:00"
                alreadyChecked:
                  summary: Paid, already told you — do NOT credit again
                  value:
                    statusCode: 200
                    data:
                      is_verified: true
                      is_checked: true
                      uuid: 56326e0f-8747-4edc-9fb5-cf8b68862e93
                      order_id: deposit-8821
                      status: paid
                      amount: 500000
                      currency: IRR
                      trace: "23213819"
                      card: 603799******4467
                      paid_at: "2026-10-12T11:27:18+00:00"
                    timestamp: "2026-10-12T11:31:02+00:00"
                notPaid:
                  summary: Not paid — nothing claimed, ask again later
                  value:
                    statusCode: 200
                    data:
                      is_verified: false
                      is_checked: false
                      uuid: 56326e0f-8747-4edc-9fb5-cf8b68862e93
                      order_id: deposit-8821
                      status: expired
                      amount: 500000
                      currency: IRR
                      trace: null
                      card: null
                      paid_at: null
                    timestamp: "2026-10-12T11:45:00+00:00"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

components:

  securitySchemes:
    ApiToken:
      type: apiKey
      in: header
      name: X-API-TOKEN
      description: |
        Your API token, from your dashboard, in the `X-API-TOKEN` header on
        every request.

        Optionally give us the public addresses of the servers that call us and
        we will refuse the token from anywhere else, so a leaked token is not
        enough on its own.

  schemas:

    Envelope:
      type: object
      required: [statusCode, data, timestamp]
      properties:
        statusCode:
          type: integer
          description: The HTTP status, repeated in the body.
          example: 200
        data:
          description: The payload, or null on an error.
          nullable: true
        timestamp:
          type: string
          format: date-time
          example: "2026-10-12T11:26:01+00:00"

    CreatePaymentRequest:
      type: object
      required: [amount]
      properties:
        amount:
          type: integer
          minimum: 1000
          maximum: 500000000
          description: Iranian Rial. No decimals.
          example: 500000
        order_id:
          type: string
          maxLength: 120
          nullable: true
          description: |
            Your own reference for this payment. Unique within your account.
            Send the same one again and you get the same payment back instead
            of a new one.
          example: deposit-8821
        callback_url:
          type: string
          format: uri
          maxLength: 255
          nullable: true
          description: |
            Where your customer's browser goes when they are done. We append
            `?uuid=…&status=paid|failed`.

            **A browser arriving here is not proof of payment** — anyone can
            type that URL. It is the signal to call `verifyTransaction`.
          example: https://shop.example/payment/return
        user_ip:
          type: string
          nullable: true
          description: |
            Lock the link so only this address can open it. Anyone else — a
            forwarded message, a screenshot, a leaked token — gets a refusal
            page and cannot see so much as the amount.

            Send the address *your* server sees for the customer. If you are
            behind a CDN, make sure that is the customer and not your edge.

            Think before using it on mobile: Iranian mobile networks move a
            subscriber between addresses, and a customer switching from mobile
            data to wi-fi locks themselves out of a link you already gave them.
            Good for desktop, poor for a phone app. Left out, the unguessable
            token is still the only way in.
          example: 5.23.112.40
        description:
          type: string
          maxLength: 255
          nullable: true
          description: For your records. Not shown to the payer.
          example: Wallet top-up

    VerifyRequest:
      type: object
      description: Send `uuid` or `order_id`. One of the two is required.
      properties:
        uuid:
          type: string
          format: uuid
          nullable: true
          description: The id from the create response, or from the redirect.
          example: 56326e0f-8747-4edc-9fb5-cf8b68862e93
        order_id:
          type: string
          maxLength: 120
          nullable: true
          description: Your own reference, if you would rather not store ours.
          example: deposit-8821

    Payment:
      type: object
      properties:
        uuid:
          type: string
          format: uuid
          description: |
            The payment's public id. Safe to log and to quote in a support
            ticket. Use it everywhere.
          example: 56326e0f-8747-4edc-9fb5-cf8b68862e93
        is_verified:
          type: boolean
          description: Did the money arrive?
          example: true
        is_checked:
          type: boolean
          description: |
            Has `verifyTransaction` already been called for this payment?
            Reading it here does **not** change it.
          example: false
        order_id: { type: string, nullable: true, example: deposit-8821 }
        status:
          $ref: '#/components/schemas/Status'
        amount: { type: integer, example: 500000 }
        currency: { type: string, enum: [IRR], example: IRR }
        url:
          type: string
          format: uri
          description: |
            Send your customer here. The token inside it is **their**
            credential — treat it the way you would a password reset link.
          example: https://varizi.site/l/bGSR4wkHM4Y2fHtK0F7bvBVaCMMThLz0W
        ip_locked:
          type: string
          nullable: true
          description: The address this link is locked to, or null.
          example: null
        description: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        paid_at: { type: string, format: date-time, nullable: true }
        trace:
          type: string
          nullable: true
          description: The bank's reference number. Null until paid.
          example: "23213819"
        card:
          type: string
          nullable: true
          description: |
            The paying card, masked by the bank. Null until paid. The full
            number is never returned by this API.
          example: 603799******4467

    Verification:
      type: object
      properties:
        is_verified:
          type: boolean
          description: Did the money arrive? The same answer every time you ask.
          example: true
        is_checked:
          type: boolean
          description: |
            Had you already been told? `false` on the first call that finds the
            payment paid, `true` for ever after. Credit when `is_verified` is
            true and this is false.
          example: false
        uuid: { type: string, format: uuid }
        order_id: { type: string, nullable: true }
        status: { $ref: '#/components/schemas/Status' }
        amount: { type: integer, example: 500000 }
        currency: { type: string, enum: [IRR] }
        trace: { type: string, nullable: true, example: "23213819" }
        card: { type: string, nullable: true, example: 603799******4467 }
        paid_at: { type: string, format: date-time, nullable: true }

    Status:
      type: string
      enum: [pending, processing, paid, failed, expired]
      description: |
        | value | means |
        |---|---|
        | `pending` | created; the customer has not started |
        | `processing` | the customer is on the bank's page |
        | `paid` | **money moved** |
        | `failed` | finished without being paid |
        | `expired` | nobody paid it in time |

        Only `paid` means money moved. `failed` does not mean "try again in a
        moment" — it means that attempt is over.
      example: paid

    Error:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          required: [error, message]
          properties:
            error:
              type: string
              description: Stable code. Switch on this.
              example: unauthorized
            message:
              type: string
              description: For your logs. Wording may change.
              example: Invalid API token.

  responses:
    Unauthorized:
      description: Missing or invalid token.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            statusCode: 401
            data: null
            error: unauthorized
            message: Invalid API token.
            timestamp: "2026-10-12T11:26:01+00:00"
    IpNotAllowed:
      description: |
        This server is not on your API allowlist. The address we saw is in the
        message — usually your own server moved.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            statusCode: 403
            data: null
            error: ip_not_allowed
            message: "This address is not on your API allowlist: 203.0.113.7"
            timestamp: "2026-10-12T11:26:01+00:00"
    NotFound:
      description: |
        No payment of yours with that id. A payment belonging to another
        merchant reports as absent rather than forbidden.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ValidationFailed:
      description: A field is missing or malformed. `errors` names the fields.
      content:
        application/json:
          schema:
            type: object
            properties:
              message: { type: string, example: The amount field must be at least 1000. }
              errors:
                type: object
                additionalProperties:
                  type: array
                  items: { type: string }
                example:
                  amount: [The amount field must be at least 1000.]
    RateLimited:
      description: 120 requests a minute per account. Honour `Retry-After`.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unavailable:
      description: |
        No gateway can take this payment right now — most often a daily cap, or
        a payment server that is temporarily unreachable. Retry in a few
        minutes. Nothing was created, so a retry is safe.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            statusCode: 503
            data: null
            error: unavailable
            message: در حال حاضر سرور پرداختی در دسترس نیست. لطفاً دقایقی بعد دوباره تلاش کنید.
            timestamp: "2026-10-12T11:26:01+00:00"
