> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bouclay.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Create a product



## OpenAPI

````yaml /api/openapi.yaml post /products
openapi: 3.1.0
info:
  title: Bouclay API
  version: 1.0.0
  summary: Integrator API for Bouclay subscriptions, invoices, payments, and webhooks.
  description: >
    Bouclay is managed recurring-billing infrastructure for African payment

    gateways. Integrators connect their own gateway account (Bachs, with Nomba,

    Paystack and Flutterwave in early access) and use this API to manage
    customers, catalog, subscriptions,

    entitlements, usage metering, invoices, payment-method checkout sessions,
    payments, and

    outbound event history.


    Bouclay never holds funds — every charge, refund, and payout happens on your

    own gateway account, with your own keys. Which gateway settles a given
    charge

    is Bouclay's concern, not yours: a stored card always charges through the

    gateway that issued its token.


    ## Early access


    Some of this API is documented ahead of its release, so you can build

    against it now. Those operations carry an **Early access** badge, and so

    do the request fields that depend on them (graduated, volume and package

    pricing, usage-based prices, usage thresholds). Until a feature is turned

    on for your account, its endpoints answer `404 not_found`, its fields are

    refused with a `422`, and a restricted key cannot be given its scopes.

    Ask us to turn it on. Nothing you already rely on changes when it is.
servers:
  - url: https://bouclay.com/api/v1
    description: Production
security:
  - SecretKeyAuth: []
tags:
  - name: Customers
  - name: Customer addresses
  - name: Payment methods
  - name: Entitlements
    description: >-
      Early access: answers `404 not_found` until it is turned on for your
      account.
  - name: Credits
    description: >-
      What a customer has left to spend. Money owed back to a customer is their
      balance, one per currency; their next invoice spends it after tax, like a
      payment, and collects the rest. Promotional credit you give is held in
      lots (credit grants), each with its own expiry; the next invoice spends it
      before tax, so it lowers the VAT like a discount.
  - name: Catalog
  - name: Payment links
    description: >-
      A shareable checkout page for one price, the same link a price's "Payment
      link" makes in the dashboard. Buyers enter their email and pay; a
      subscription price starts the subscription once paid.
  - name: Meters
    description: >-
      Early access: answers `404 not_found` until it is turned on for your
      account.
  - name: Usage
    description: >-
      Early access: answers `404 not_found` until it is turned on for your
      account.
  - name: Discounts
    description: >-
      Early access: answers `404 not_found` until it is turned on for your
      account.
  - name: Subscriptions
  - name: Invoices
  - name: Metrics
    description: >-
      Early access: answers `404 not_found` until it is turned on for your
      account. Revenue metrics measured from each subscription's monthly
      recurring amount (MRR). `active` and `past_due` subscriptions count;
      trials, pauses and metered usage do not. Amounts are major units, rates
      are percentages (105.25 means 105.25%), and currencies are never summed.
  - name: Credit notes
    description: >-
      A credit note corrects an issued invoice without changing it: "of this
      invoice, this much is no longer owed". Each one has its own number in an
      unbroken sequence (`{prefix}-CN-0001`) and never changes once issued. On
      an unpaid invoice the credit comes off `amountDue`; on a paid one it goes
      back to the customer, refunded to the card, added to the customer balance
      or settled outside Bouclay. What the customer balance paid of an invoice
      always goes back to the balance first. An invoice always satisfies
      `amountDue = total - amountPaid - amountCredited - balanceApplied`. Every
      refund also issues a credit note.
  - name: Payments
  - name: Refunds
    description: >-
      A refund goes back through the gateway that took the payment. Refund part
      of a payment and it stays `succeeded`; refund all of it and it becomes
      `refunded`.
  - name: Chargebacks
    description: >-
      A chargeback is a payment the customer's bank took back. Record one when
      your payment provider reports it, with the provider's dispute reference,
      then record how the dispute ended. While it is open, Bouclay doesn't
      charge or chase the subscription. A chargeback on a credit top-up takes
      back the unspent credit and its bonus; winning the dispute puts back
      exactly that. Uses the `refunds` scope.
  - name: Events
  - name: Checkout sessions
  - name: Portal sessions
  - name: Webhook endpoints
paths:
  /products:
    post:
      tags:
        - Catalog
      summary: Create a product
      operationId: createProduct
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateProductRequest'
      responses:
        '201':
          $ref: '#/components/responses/Product'
        '400':
          $ref: '#/components/responses/Error'
        '422':
          $ref: '#/components/responses/Error'
components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
        minLength: 1
        maxLength: 255
      description: >-
        Required for POST and PATCH requests. Replays identical method + path +
        body requests and rejects mismatched re-use.
  schemas:
    CreateProductRequest:
      type: object
      required:
        - name
      properties:
        name:
          type: string
        description:
          type:
            - string
            - 'null'
        category:
          type:
            - string
            - 'null'
        taxCategory:
          description: >-
            How tax treats this product under your country's tax law. Defaults
            to `standard`.
          type: string
          enum:
            - standard
            - zero_rated
            - exempt
        revenueCode:
          description: >-
            The account in your books this product's sales post to. Frozen onto
            each invoice line.
          type:
            - string
            - 'null'
          maxLength: 64
        kind:
          type: string
          enum:
            - standard
            - credit
          description: >-
            `credit` makes a credit pack preset: its prices are one-time packs
            of prepaid credit, and it can't have plans.
        creditVatTiming:
          type: string
          enum:
            - at_redemption
            - at_purchase
          description: >-
            Credit products only: when VAT is charged on credit bought as one of
            its packs. `at_purchase` taxes the top-up and spends the credit
            before tax; `at_redemption` leaves the top-up untaxed and spends the
            credit after tax. Defaults to your team default.
        creditExpiresAfterMonths:
          type:
            - integer
            - 'null'
          minimum: 1
          maximum: 120
          description: Credit products only. Null for credit that never expires.
        customData:
          $ref: '#/components/schemas/CustomData'
        price:
          $ref: '#/components/schemas/PriceRequest'
    CustomData:
      type:
        - object
        - 'null'
      additionalProperties: true
    PriceRequest:
      type: object
      required:
        - type
        - pricingModel
      properties:
        name:
          type:
            - string
            - 'null'
        type:
          type: string
          enum:
            - one_time
            - recurring
        pricingModel:
          type: string
          enum:
            - standard
            - graduated
            - volume
            - package
          description: |
            **Early access:** `graduated`, `volume` and `package` are refused
            until tiered pricing is turned on for your account; `standard`
            always works.

            `graduated` prices each slice of the quantity at its own tier's
            rate. `volume` prices every unit at the rate of the tier the
            whole quantity lands in, plus that tier's flat fee, so 12 seats
            on "1 to 10 at 5,000, 11 or more at 4,000" cost 12 × 4,000.
            `volume` is for fixed (`licensed`) prices only; a usage price
            uses `graduated`.

            `package` is for usage prices only: usage is rounded up to whole
            packages of `packageSize` units, each charged `unitAmount`.
        unitAmount:
          $ref: '#/components/schemas/MajorAmount'
        usageType:
          type: string
          enum:
            - licensed
            - metered
          default: licensed
          description: |
            **Early access:** `metered` is refused until metered billing is
            turned on for your account.

            `metered` charges for what a meter measured over each period,
            billed at the end of it. It needs `meterId`, a recurring price,
            and `billingTiming` left out or `arrears`. The subscription item's
            quantity is always 1.
        meterId:
          type: string
          description: >-
            The meter a `metered` price charges for (`mtr_…`). Early access,
            with `metered`.
        packageSize:
          type: integer
          minimum: 1
          description: Units per package, for `package` pricing.
        unitDivisor:
          type: integer
          minimum: 1
          maximum: 1000000000
          default: 1
          description: |
            "Per N units" pricing for a `metered` price: `unitAmount` and every
            tier's `unitAmount` are the price of this many units, so a unit
            can cost less than the currency's smallest unit. ₦4,500 per
            1,000,000 tokens is `unitAmount: 4500, unitDivisor: 1000000`.
            A usage line bills `usage × unitAmount ÷ unitDivisor`, tier slices
            summed first, rounded half up to the minor unit once per line:
            10 tokens bill ₦0.05, 2,345,678 tokens ₦10,555.55. Tier `upTo`
            bounds stay in units and a tier's `flatAmount` is never divided.
            Refused on a `licensed` price and with `package` pricing (a
            package rounds usage up to whole blocks). Frozen like the rest of
            the price.
        minQuantity:
          type: integer
          minimum: 1
          description: |
            The fewest one purchase of this price can hold, for a `licensed`
            price ("Team: 5 seats or more"). Leave it out for no minimum. A
            subscription, item change or invoice line below it is refused. A
            payment link on a price with a range lets the buyer choose a
            quantity inside it (up to 1,000).
        maxQuantity:
          type: integer
          minimum: 1
          description: >-
            The most one purchase of this price can hold, for a `licensed`
            price. At least `minQuantity`.
        creditAmount:
          type: number
          exclusiveMinimum: 0
          description: >-
            A credit pack only: the credit it gives, when more than `unitAmount`
            ("pay ₦50,000, get ₦55,000"). The difference is a bonus, its own
            promotional lot.
        setupFee:
          type: number
          exclusiveMinimum: 0
          description: >-
            A `recurring` price only: a one-time fee (a gym's joining fee, a
            setup fee) billed once when a subscription starts on this price, on
            its first paid invoice: at once, or when a free trial ends. Never
            billed on a renewal, a plan change or a resume. Payment links show
            it separately and charge it with the first payment. Fixed once the
            price is in use, like `unitAmount`.
        setupFeeName:
          type: string
          maxLength: 100
          description: >-
            The setup fee's line on the invoice, such as "Joining fee". Defaults
            to "Setup fee".
        setupFeeAgainAfterMonths:
          type:
            - integer
            - 'null'
          minimum: 1
          maximum: 120
          description: >-
            A returning customer pays the setup fee again only once this many
            months have passed since their last subscription to the product
            ended; back sooner, they rejoin without it. Leave it out to charge
            it on every new subscription. A subscription still live doesn't
            count: a second one pays it. A payment link's page shows the fee to
            everyone, and the checkout leaves it off for a returning buyer.
        tiers:
          type: array
          description: >
            Required for `graduated` and `volume` pricing. Included units are a
            first tier

            priced at 0, for example `upTo: 10000, unitAmount: 0` then

            `upTo: null, unitAmount: 3`.
          items:
            type: object
            required:
              - unitAmount
            properties:
              upTo:
                type:
                  - integer
                  - 'null'
                minimum: 1
              unitAmount:
                $ref: '#/components/schemas/MajorAmount'
              flatAmount:
                $ref: '#/components/schemas/MajorAmount'
        currency:
          type:
            - string
            - 'null'
          minLength: 3
          maxLength: 3
        billingInterval:
          type:
            - string
            - 'null'
          enum:
            - day
            - week
            - month
            - year
            - null
        billingFrequency:
          type: integer
          minimum: 1
          default: 1
        billingTiming:
          type: string
          enum:
            - advance
            - arrears
          default: advance
          description: |
            **Early access:** `arrears` is refused until metered billing is
            turned on for your account.

            When a recurring price is billed. `advance` charges at the start of
            each period. `arrears` charges once the period has ended, prorated
            to the time the subscription had it, so nothing is due at signup.
            Ignored for one-time prices. Fixed once the price is in use.
        planId:
          type: string
          description: |
            The plan a recurring price belongs to (`plan_…`), required for
            recurring prices on `POST /products/{id}/prices`. An inline
            `price` on `POST /products` takes `planName` instead and gets a
            plan created alongside.
        chargeId:
          type: string
          description: |
            Which of the plan's charges the price is for (`chg_…`). Required
            when the plan has several; otherwise the price goes to its only
            charge. A charge's prices are all fixed, or all usage of one
            meter, and a meter is billed by one charge per plan.
        planName:
          type: string
          description: >-
            Inline `price` on `POST /products` only; the plan created for a
            recurring price. Defaults to the product name.
        trialLength:
          type: integer
          minimum: 1
          description: A free trial on this price. Recurring prices only.
        trialUnit:
          type: string
          enum:
            - day
            - week
            - month
          description: Required with `trialLength`.
        trialRequiresPaymentInfo:
          type: boolean
          default: false
          description: >-
            The trial starts only once the customer saves a card (a small
            charge, refunded): the subscription waits `incomplete` until then.
            Without it, the trial starts at once with no card.
        trialOncePerCustomer:
          type: boolean
          default: true
          description: Once per customer per plan, across all the plan's prices.
        customData:
          $ref: '#/components/schemas/CustomData'
    ResourceEnvelope:
      type: object
      required:
        - data
        - meta
      properties:
        data:
          type: object
        meta:
          $ref: '#/components/schemas/Meta'
    Product:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type:
            - string
            - 'null'
        category:
          type:
            - string
            - 'null'
        taxCategory:
          type: string
          enum:
            - standard
            - zero_rated
            - exempt
        revenueCode:
          type:
            - string
            - 'null'
        kind:
          type: string
          enum:
            - standard
            - credit
        creditVatTiming:
          type:
            - string
            - 'null'
          enum:
            - at_redemption
            - at_purchase
            - null
        creditExpiresAfterMonths:
          type:
            - integer
            - 'null'
        status:
          type: string
          enum:
            - active
            - archived
        customData:
          $ref: '#/components/schemas/CustomData'
        createdAt:
          type:
            - string
            - 'null'
          format: date-time
        archivedAt:
          type:
            - string
            - 'null'
          format: date-time
        priceIds:
          type: array
          items:
            type: string
    ErrorResponse:
      type: object
      required:
        - error
        - meta
      properties:
        error:
          type: object
          required:
            - type
            - code
            - detail
          properties:
            type:
              type: string
              enum:
                - request_error
                - api_error
              description: >-
                `api_error` with code `internal_error` (HTTP 500) means the
                fault is ours: retry with the same Idempotency-Key.
              example: request_error
            code:
              type: string
              enum:
                - authentication_failed
                - permission_denied
                - invalid_field
                - invalid_cursor
                - idempotency_conflict
                - meter_not_found
                - meter_archived
                - customer_not_found
                - timestamp_in_future
                - usage_period_closed
                - subscription_paused
                - missing_property
                - invalid_value
                - conflict
                - subscription_suspended
                - no_upcoming_invoice
                - not_found
                - request_error
                - internal_error
            detail:
              type: string
            errors:
              type: array
              items:
                type: object
                required:
                  - field
                  - message
                properties:
                  field:
                    type: string
                  message:
                    type: string
        meta:
          $ref: '#/components/schemas/Meta'
    MajorAmount:
      type:
        - number
        - 'null'
      minimum: 0
      description: Major currency units. For example, 15000 means NGN 15,000.
    Meta:
      type: object
      required:
        - requestId
      properties:
        requestId:
          type: string
          example: req_550e8400-e29b-41d4-a716-446655440000
  responses:
    Product:
      description: Product response.
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/ResourceEnvelope'
              - type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Product'
    Error:
      description: Structured API error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
  securitySchemes:
    SecretKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: '{sk|rk}_{mode}_{random}'
      description: >
        Use a Bouclay secret key (`sk_test_...`, `sk_live_...`) or restricted
        key

        (`rk_test_...`, `rk_live_...`).


        A secret key can call every endpoint. A restricted key can only call the

        endpoints its scopes cover. A scope is `{resource}:{access}`, for
        example

        `customers:read` or `refunds:write`, and write includes read. `GET`

        requests need read access and everything else needs write, except

        `POST /subscriptions/{id}/items/{item}/preview`, which only needs

        `subscriptions:read`. A request outside the key's scopes gets `403

        permission_denied` naming the missing scope, and its `Idempotency-Key`

        is not used up.


        | Resource | Endpoints | Access |

        | --- | --- | --- |

        | `customers` | customers, their addresses and payment methods | read,
        write |

        | `entitlements` | `/customers/{id}/entitlements` | read |

        | `credits` | `/customers/{id}/credit-balance`, credit purchases, credit
        grants, auto-reloads, credit holds (committing one records its usage) |
        read, write |

        | `catalog` | products, plans, prices | read, write |

        | `meters` | meters | read, write |

        | `usage` | meter events, `/customers/{id}/usage` | read, write |

        | `discounts` | discounts | read |

        | `subscriptions` | subscriptions | read, write |

        | `invoices` | invoices | read, write |

        | `credit_notes` | credit notes, `POST /invoices/{id}/credit-notes` (a
        refund to the card also needs `refunds:write`) | read, write |

        | `payments` | payments | read |

        | `refunds` | refunds, `POST /payments/{id}/refunds`, chargebacks and
        their outcome | read, write |

        | `checkout_sessions` | checkout sessions | read, write |

        | `portal_sessions` | portal sessions | write |

        | `events` | events | read |

        | `webhook_endpoints` | webhook endpoints | read, write |

        | `metrics` | metrics | read |


        Publishable keys (`pk_...`) are rejected by server-side API endpoints. A

        key's mode scopes what it can see and charge: a test key can never reach

        a live card.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.