openapi: 3.1.0
info:
  title: Vouchereto Merchant API
  version: "1.0.0"
  summary: Accept Vouchereto prepaid vouchers as a payment or top-up method.
  description: |
    Reserve and capture voucher redemptions, send customers to the hosted checkout or the hosted redeem widget,
    create test vouchers in the sandbox and list settlements.

    **Environments.** One host for both. The API key decides the mode: `vch_test_…` keys work on sandbox data,
    `vch_live_…` keys on real money (only once your merchant account is `live`). Data never crosses modes.

    **Authentication.** Every request carries `Authorization: Bearer <api key>` **and** a `Vouchereto-Signature`
    header: `t=<unix seconds>,v1=<hex HMAC-SHA256>` computed with your key's secret (the last `_` segment of the key)
    over `t + "." + METHOD + " " + path_with_query + "\n" + raw_body`. The path includes `/v1`. GET requests sign an
    empty body. Clock tolerance: 300 seconds. You may send several `v1=` values (useful while rotating keys).

    **Idempotency.** Every POST requires an `Idempotency-Key` header (1–128 chars). Repeating a request with the same
    key and body returns the original response with `Idempotent-Replayed: true`; the same key with a different body
    returns `409 IDEMPOTENCY_CONFLICT`.

    **Errors** follow RFC 7807 (`application/problem+json`). Always log `request_id`; support uses it to find your call.

    Guides, webhooks and test credentials: https://www.vouchereto.com/docs
  contact:
    name: Vouchereto developer support
    url: https://www.vouchereto.com/en/contact?topic=developer
servers:
  - url: https://api.vouchereto.com/v1
    description: Production and sandbox (the key prefix selects the mode)
  - url: http://localhost:3003/v1
    description: Local development stack (scripts/local/start-all.sh)
security:
  - apiKey: []
    signature: []
tags:
  - name: Redemptions
    description: Two-step redemption of a voucher code your customer typed in your site (reserve, then capture or cancel).
  - name: Checkout
    description: Hosted "Pay with voucher" checkout. The customer buys a voucher by card on pay.vouchereto.com and comes back.
  - name: Redeem widget
    description: Hosted page where the customer types an existing voucher code, so the code never touches your site.
  - name: Sandbox
    description: Test-mode helpers. Test keys only.
  - name: Settlements
    description: Weekly payouts of captured redemptions.

paths:
  /redemptions:
    post:
      tags: [Redemptions]
      operationId: createRedemption
      summary: Reserve a voucher (step 1 of 2)
      description: |
        Locks the voucher's full value for `ttl_seconds`. Nothing is charged until you capture. If you neither capture nor
        cancel, the reservation expires and the voucher becomes usable again (`redemption.expired` webhook).
        Vouchers are single-use: the whole face value is redeemed.

        Invalid codes are rate limited per `customer_ref`, per customer IP and per merchant (5 failures / 15 min per
        customer, then exponential blocks). Send `X-Customer-Ip` so one abusive customer cannot block your server's IP.
      x-rate-limit: 120 requests / minute / key
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/Signature'
        - $ref: '#/components/parameters/CustomerIp'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RedemptionCreate' }
            example: { code: 'K7QM-4XPD-9TRW-2WNZ', currency: USD, customer_ref: user_8842, ttl_seconds: 900 }
      responses:
        '201':
          description: Reserved
          headers: { Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' } }
          content: { application/json: { schema: { $ref: '#/components/schemas/Redemption' } } }
        '400': { $ref: '#/components/responses/Problem' }
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
        '404': { $ref: '#/components/responses/Problem' }
        '409': { $ref: '#/components/responses/Problem' }
        '422': { $ref: '#/components/responses/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      tags: [Redemptions]
      operationId: listRedemptions
      summary: List redemptions
      description: Newest first. Cursor pagination.
      parameters:
        - $ref: '#/components/parameters/Signature'
        - { name: status, in: query, schema: { $ref: '#/components/schemas/RedemptionStatus' } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
        - { name: cursor, in: query, description: '`pagination.next_cursor` from the previous page', schema: { type: string } }
      responses:
        '200':
          description: A page of redemptions
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Redemption' } }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/Problem' }
        '401': { $ref: '#/components/responses/Problem' }
  /redemptions/{id}:
    get:
      tags: [Redemptions]
      operationId: getRedemption
      summary: Retrieve a redemption
      parameters: [{ $ref: '#/components/parameters/Id' }, { $ref: '#/components/parameters/Signature' }]
      responses:
        '200': { description: The redemption, content: { application/json: { schema: { $ref: '#/components/schemas/Redemption' } } } }
        '404': { $ref: '#/components/responses/Problem' }
  /redemptions/{id}/capture:
    post:
      tags: [Redemptions]
      operationId: captureRedemption
      summary: Capture a reservation (step 2 of 2)
      description: Credits your merchant balance with the voucher's value. Credit your customer only after a 200 here (or the `redemption.captured` webhook).
      parameters: [{ $ref: '#/components/parameters/Id' }, { $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/Signature' }]
      responses:
        '200': { description: Captured, content: { application/json: { schema: { $ref: '#/components/schemas/Redemption' } } } }
        '404': { $ref: '#/components/responses/Problem' }
        '409': { $ref: '#/components/responses/Problem' }
  /redemptions/{id}/cancel:
    post:
      tags: [Redemptions]
      operationId: cancelRedemption
      summary: Cancel a reservation
      description: Releases the voucher so the customer can use it again. Only reservations can be cancelled; a captured redemption is final.
      parameters: [{ $ref: '#/components/parameters/Id' }, { $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/Signature' }]
      responses:
        '200': { description: Cancelled, content: { application/json: { schema: { $ref: '#/components/schemas/Redemption' } } } }
        '404': { $ref: '#/components/responses/Problem' }
        '409': { $ref: '#/components/responses/Problem' }

  /checkout-sessions:
    post:
      tags: [Checkout]
      operationId: createCheckoutSession
      summary: Create a hosted checkout session
      description: |
        Redirect your customer to `url`. They buy a voucher by card (3D Secure) on pay.vouchereto.com. With
        `auto_redeem: true` (default) the voucher is reserved and captured for you in the same step and the session
        becomes `completed` with a `redemption_id`. The customer is sent back to
        `return_url?vouchereto_session=<id>&status=<status>`. Trust the `checkout.completed` webhook or
        `GET /checkout-sessions/{id}`, never the query string alone. Sessions expire after 60 minutes.
      x-rate-limit: 300 requests / minute / key
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/Signature' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckoutSessionCreate' }
            example: { amount_minor: 5000, currency: USD, customer_ref: user_8842, locale: en,
                       return_url: 'https://shop.example.com/topup/done', cancel_url: 'https://shop.example.com/topup',
                       metadata: { cart: '3141' } }
      responses:
        '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/CheckoutSession' } } } }
        '400': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
        '422': { $ref: '#/components/responses/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /checkout-sessions/{id}:
    get:
      tags: [Checkout]
      operationId: getCheckoutSession
      summary: Retrieve a checkout session
      parameters: [{ $ref: '#/components/parameters/Id' }, { $ref: '#/components/parameters/Signature' }]
      responses:
        '200': { description: The session, content: { application/json: { schema: { $ref: '#/components/schemas/CheckoutSession' } } } }
        '404': { $ref: '#/components/responses/Problem' }

  /redeem-sessions:
    post:
      tags: [Redeem widget]
      operationId: createRedeemSession
      summary: Create a hosted redeem session
      description: |
        Redirect your customer to `url`; they type their code on pay.vouchereto.com. They return to
        `return_url?vouchereto_redemption=<id>&status=<reserved|captured>`. With `auto_capture: false` (default) you must
        capture or cancel the redemption yourself. Sessions expire after 30 minutes.
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/Signature' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RedeemSessionCreate' }
            example: { currency: USD, customer_ref: user_8842, return_url: 'https://shop.example.com/wallet', auto_capture: false }
      responses:
        '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/RedeemSession' } } } }
        '400': { $ref: '#/components/responses/Problem' }
        '422': { $ref: '#/components/responses/Problem' }

  /sandbox/vouchers:
    post:
      tags: [Sandbox]
      operationId: createSandboxVoucher
      summary: Create a test voucher
      description: Test keys only (a live key gets `403 LIVEMODE_NOT_ALLOWED`). Returns a real, valid code in the state you ask for, tied to your merchant.
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/Signature' }]
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SandboxVoucherCreate' }
            example: { state: active, amount_minor: 5000, currency: USD }
      responses:
        '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/SandboxVoucher' } } } }
        '403': { $ref: '#/components/responses/Problem' }
        '422': { $ref: '#/components/responses/Problem' }

  /settlements:
    get:
      tags: [Settlements]
      operationId: listSettlements
      summary: List settlements
      description: Latest 100, newest first. Test keys always get an empty list (no money moves in the sandbox). The line-level CSV is in the merchant portal.
      parameters: [{ $ref: '#/components/parameters/Signature' }]
      responses:
        '200':
          description: Settlements
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Settlement' } }
                  pagination: { $ref: '#/components/schemas/Pagination' }

webhooks:
  redemption.captured: { post: { $ref: '#/components/pathItems/WebhookPost/post' } }
  redemption.cancelled: { post: { $ref: '#/components/pathItems/WebhookPost/post' } }
  redemption.expired: { post: { $ref: '#/components/pathItems/WebhookPost/post' } }
  checkout.completed: { post: { $ref: '#/components/pathItems/WebhookPost/post' } }
  checkout.expired: { post: { $ref: '#/components/pathItems/WebhookPost/post' } }
  checkout.failed: { post: { $ref: '#/components/pathItems/WebhookPost/post' } }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: 'vch_(live|test)_<12 chars>_<32 chars>'
    signature:
      type: apiKey
      in: header
      name: Vouchereto-Signature
      description: 't=<unix>,v1=<hex HMAC-SHA256(key secret, t + "." + METHOD + " " + path_with_query + "\n" + raw_body)>'

  parameters:
    Id: { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Unique per logical operation (for example your order id). Reuse it on retries.
      schema: { type: string, minLength: 1, maxLength: 128 }
    Signature:
      name: Vouchereto-Signature
      in: header
      required: true
      schema: { type: string, example: 't=1767225600,v1=5f2b…' }
    CustomerIp:
      name: X-Customer-Ip
      in: header
      required: false
      description: Your end customer's IP. Used for brute-force protection instead of your server's IP.
      schema: { type: string }

  headers:
    IdempotentReplayed: { description: Present (`true`) when this is a stored response to a repeated Idempotency-Key, schema: { type: string } }

  responses:
    Problem:
      description: Error (RFC 7807)
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    RateLimited:
      description: Too many requests
      headers: { Retry-After: { description: Seconds to wait, schema: { type: integer } } }
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }

  pathItems:
    WebhookPost:
      post:
        summary: Event sent to your endpoint
        description: |
          Headers: `Vouchereto-Signature: t=<unix>,v1=<hex HMAC-SHA256(whsec secret, t + "." + raw_body)>`,
          `Vouchereto-Event-Id`, `User-Agent: Vouchereto-Webhooks/1.0`. Reply 2xx within 10 seconds. Retries after
          1 m, 5 m, 30 m, 2 h, 6 h, 12 h, 24 h, 24 h, then the delivery is marked dead (resend it from the portal).
        requestBody:
          content: { application/json: { schema: { $ref: '#/components/schemas/WebhookEvent' } } }
        responses: { '200': { description: Acknowledged } }

  schemas:
    Currency: { type: string, pattern: '^[A-Z]{3}$', description: ISO 4217 code accepted by your merchant account, example: USD }
    AmountMinor: { type: integer, description: 'Amount in minor units (cents). JPY and KRW have no decimals: 1000 = ¥1,000.', example: 5000 }
    RedemptionStatus: { type: string, enum: [reserved, captured, cancelled, expired] }
    Pagination:
      type: object
      required: [has_more, next_cursor]
      properties: { has_more: { type: boolean }, next_cursor: { type: [string, 'null'] } }

    RedemptionCreate:
      type: object
      additionalProperties: false
      required: [code, currency, customer_ref]
      properties:
        code: { type: string, minLength: 16, maxLength: 40, description: 'Code as typed by the customer; dashes and spaces are ignored' }
        currency: { $ref: '#/components/schemas/Currency' }
        customer_ref: { type: string, minLength: 1, maxLength: 128, description: Your id for the customer (no personal data) }
        ttl_seconds: { type: integer, minimum: 60, maximum: 1800, default: 900 }
    Redemption:
      type: object
      required: [id, object, status, amount_minor, currency, voucher_last4, customer_ref, livemode, expires_at, captured_at, created_at]
      properties:
        id: { type: string, format: uuid }
        object: { const: redemption }
        status: { $ref: '#/components/schemas/RedemptionStatus' }
        amount_minor: { $ref: '#/components/schemas/AmountMinor' }
        currency: { $ref: '#/components/schemas/Currency' }
        voucher_last4: { type: string, example: 2WNZ }
        customer_ref: { type: string }
        livemode: { type: boolean }
        expires_at: { type: string, format: date-time, description: When an uncaptured reservation lapses }
        captured_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }

    CheckoutSessionCreate:
      type: object
      additionalProperties: false
      required: [amount_minor, currency, customer_ref, return_url, cancel_url]
      properties:
        amount_minor: { $ref: '#/components/schemas/AmountMinor' }
        currency: { $ref: '#/components/schemas/Currency' }
        customer_ref: { type: string, minLength: 1, maxLength: 128 }
        customer_email: { type: string, format: email, maxLength: 254, description: Prefills the email field }
        country: { type: string, pattern: '^[A-Z]{2}$', description: Customer country (ISO 3166-1 alpha-2) }
        locale: { type: string, enum: [en, es, ja, ko, pt, it, de], default: en }
        return_url: { type: string, format: uri, description: 'https:// (http://localhost allowed outside production)' }
        cancel_url: { type: string, format: uri }
        auto_redeem: { type: boolean, default: true, description: Apply the purchased voucher to your merchant in the same step }
        metadata: { type: object, maxProperties: 20, additionalProperties: { type: string, maxLength: 500 } }
    CheckoutSession:
      type: object
      required: [id, object, url, status, amount_minor, currency, customer_ref, locale, auto_redeem, livemode, redemption_id, metadata, expires_at]
      properties:
        id: { type: string, format: uuid }
        object: { const: checkout_session }
        url: { type: string, format: uri, example: 'https://pay.vouchereto.com/c/0b7c…' }
        status: { type: string, enum: [open, paid, completed, expired, cancelled, failed] }
        amount_minor: { $ref: '#/components/schemas/AmountMinor' }
        currency: { $ref: '#/components/schemas/Currency' }
        customer_ref: { type: string }
        locale: { type: string }
        auto_redeem: { type: boolean }
        livemode: { type: boolean }
        redemption_id: { type: [string, 'null'], format: uuid }
        metadata: { type: object, additionalProperties: { type: string } }
        expires_at: { type: string, format: date-time }

    RedeemSessionCreate:
      type: object
      additionalProperties: false
      required: [currency, customer_ref, return_url]
      properties:
        currency: { $ref: '#/components/schemas/Currency' }
        customer_ref: { type: string, minLength: 1, maxLength: 128 }
        return_url: { type: string, format: uri }
        auto_capture: { type: boolean, default: false }
    RedeemSession:
      type: object
      required: [id, object, url, status, auto_capture, livemode, expires_at]
      properties:
        id: { type: string, format: uuid }
        object: { const: redeem_session }
        url: { type: string, format: uri, example: 'https://pay.vouchereto.com/r/5d1e…' }
        status: { type: string }
        auto_capture: { type: boolean }
        livemode: { type: boolean }
        expires_at: { type: string, format: date-time }

    SandboxVoucherCreate:
      type: object
      additionalProperties: false
      properties:
        state: { type: string, enum: [active, reserved, redeemed, expired, risk_hold, void], default: active }
        amount_minor: { type: integer, minimum: 100, maximum: 10000000, default: 5000 }
        currency: { $ref: '#/components/schemas/Currency' }
    SandboxVoucher:
      type: object
      required: [id, object, code, state, amount_minor, currency, livemode]
      properties:
        id: { type: string, format: uuid }
        object: { const: sandbox_voucher }
        code: { type: string, example: 'K7QM-4XPD-9TRW-2WNZ' }
        state: { type: string }
        amount_minor: { $ref: '#/components/schemas/AmountMinor' }
        currency: { $ref: '#/components/schemas/Currency' }
        livemode: { const: false }

    Settlement:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { const: settlement }
        currency: { $ref: '#/components/schemas/Currency' }
        period_start: { type: string, format: date-time }
        period_end: { type: string, format: date-time }
        gross_minor: { type: integer }
        commission_minor: { type: integer }
        net_minor: { type: integer }
        status: { type: string }

    WebhookEvent:
      type: object
      required: [id, type, created_at, livemode, data]
      properties:
        id: { type: string, format: uuid, description: Same as the Vouchereto-Event-Id header; use it to deduplicate }
        type: { type: string, enum: [redemption.captured, redemption.cancelled, redemption.expired, checkout.completed, checkout.expired, checkout.failed] }
        created_at: { type: string, format: date-time }
        livemode: { type: boolean }
        data:
          oneOf:
            - title: Redemption event data
              type: object
              properties:
                id: { type: string, format: uuid }
                status: { $ref: '#/components/schemas/RedemptionStatus' }
                amount_minor: { type: integer }
                currency: { type: string }
                customer_ref: { type: string }
                voucher_last4: { type: string }
                captured_at: { type: [string, 'null'], format: date-time }
            - title: Checkout event data
              type: object
              properties:
                id: { type: string, format: uuid }
                status: { type: string }
                amount_minor: { type: integer }
                currency: { type: string }
                customer_ref: { type: string }
                redemption_id: { type: [string, 'null'], format: uuid }
                metadata: { type: object }

    Problem:
      type: object
      required: [type, title, status, code]
      properties:
        type: { type: string, format: uri, example: 'https://www.vouchereto.com/docs/errors/voucher_invalid' }
        title: { type: string, example: voucher invalid }
        status: { type: integer, example: 404 }
        code:
          type: string
          enum: [UNAUTHENTICATED, INVALID_SIGNATURE, FORBIDDEN, MERCHANT_NOT_ACTIVE, LIVEMODE_NOT_ALLOWED, NOT_FOUND,
                 VOUCHER_INVALID, REDEMPTION_NOT_FOUND, SESSION_NOT_FOUND, VOUCHER_RESERVED, VOUCHER_ALREADY_REDEEMED,
                 REDEMPTION_NOT_CAPTURABLE, REDEMPTION_ALREADY_CAPTURED, IDEMPOTENCY_CONFLICT, IDEMPOTENCY_IN_PROGRESS,
                 VOUCHER_EXPIRED, CURRENCY_MISMATCH, CURRENCY_NOT_ACCEPTED, UNIVERSAL_NOT_ACCEPTED, AMOUNT_OUT_OF_RANGE,
                 COUNTRY_NOT_ALLOWED, PSP_PROGRAM_NOT_APPROVED, STATE_INVALID, VALIDATION_ERROR, IDEMPOTENCY_KEY_REQUIRED,
                 RATE_LIMITED, TOO_MANY_INVALID_ATTEMPTS]
        detail: { type: string, description: 'For INVALID_SIGNATURE: MALFORMED, TIMESTAMP_OUT_OF_RANGE or MISMATCH' }
        request_id: { type: string }
