openapi: 3.1.0
info:
  title: Octany API
  version: "2025-04-25"
  summary: Public REST API for the Octany billing engine.
  description: |
    The Octany API lets you manage subscriptions, products, orders, and
    webhook endpoints. All endpoints are scoped to an account by including
    the account UUID in the URL path.

    **This file is the canonical machine-readable contract.** Pair it with
    [`integration.md`](integration.md) for narrative guidance and
    [`webhooks.md`](webhooks.md) for event payloads.

    Coverage notes:
    - Partner-platform endpoints are intentionally out of scope here.
    - Read-only resources are documented with full schemas. POST/PATCH bodies
      reflect the public request contract as of the version above.
  contact:
    name: Octany
    url: https://www.octany.se

servers:
  - url: https://api.octany.com/api/{account}
    description: Production
    variables:
      account:
        description: Your account UUID. Find it in the admin URL or the API tokens page.
        default: 00000000-0000-0000-0000-000000000000

security:
  - apiKey: []

tags:
  - name: Subscriptions
  - name: Products
  - name: Orders
  - name: Webhooks

paths:
  /subscriptions:
    get:
      tags: [Subscriptions]
      summary: List subscriptions
      parameters:
        - $ref: "#/components/parameters/Page"
        - in: query
          name: filter[reference_id]
          schema: { type: string }
          description: Match the `reference_id` you stored when the subscription was created.
      responses:
        "200":
          description: Paginated list of subscriptions.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubscriptionList"
        "401": { $ref: "#/components/responses/Unauthorized" }

  /subscription/{id}:
    parameters:
      - $ref: "#/components/parameters/SubscriptionId"
    get:
      tags: [Subscriptions]
      summary: Retrieve a subscription
      responses:
        "200":
          description: The subscription.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Subscription" }
        "404": { $ref: "#/components/responses/NotFound" }

  /subscription/{id}/cancel:
    parameters:
      - $ref: "#/components/parameters/SubscriptionId"
    post:
      tags: [Subscriptions]
      summary: Cancel a subscription
      description: |
        Cancellation is immediate from a billing perspective: no further
        renewals will be attempted. Depending on the product, the subscription
        may remain `active` until `ends_at` (the end of the current paid period).
      responses:
        "200":
          description: The cancelled subscription.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Subscription" }

  /subscription/{id}/product:
    parameters:
      - $ref: "#/components/parameters/SubscriptionId"
    post:
      tags: [Subscriptions]
      summary: Swap the product on a subscription
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_id]
              properties:
                product_id:
                  type: integer
                  description: ID of the new product (must belong to the same account).
      responses:
        "200":
          description: The updated subscription.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Subscription" }

  /subscription/{id}/orders:
    parameters:
      - $ref: "#/components/parameters/SubscriptionId"
    get:
      tags: [Subscriptions, Orders]
      summary: List orders for a subscription
      parameters:
        - $ref: "#/components/parameters/Page"
        - in: query
          name: per_page
          schema: { type: integer, default: 20, minimum: 1, maximum: 200 }
        - in: query
          name: filter[product_id]
          schema: { type: integer }
        - in: query
          name: filter[state]
          schema:
            oneOf:
              - { type: string, description: "Single state, e.g. `paid`." }
              - { type: array, items: { $ref: "#/components/schemas/OrderState" } }
          description: Comma-separate or repeat to pass multiple states.
      responses:
        "200":
          description: Paginated list of orders, latest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderList"

  /subscription/{id}/order:
    parameters:
      - $ref: "#/components/parameters/SubscriptionId"
    post:
      tags: [Subscriptions, Orders]
      summary: Create a one-off charge on a subscription
      description: |
        Adds a one-time order to an existing subscription, billed via the
        subscription's billing method. Only allowed on non-donation
        subscriptions.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_id, amount]
              properties:
                product_id:
                  type: integer
                  description: ID of a one-time product on this account.
                amount:
                  type: integer
                  description: Amount in cents.
                  example: 4995
                quantity:
                  type: integer
                  default: 1
                vat:
                  type: integer
                  default: 2500
                  description: VAT in basis points (2500 = 25%).
                description:
                  type: string
      responses:
        "200":
          description: The created order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Order" }
        "402":
          description: The charge could not be processed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /products:
    get:
      tags: [Products]
      summary: List products
      parameters:
        - $ref: "#/components/parameters/Page"
      responses:
        "200":
          description: Paginated list of products (default 100/page).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductList"

  /product/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer }
    get:
      tags: [Products]
      summary: Retrieve a product
      responses:
        "200":
          description: The product.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Product" }
    post:
      tags: [Products]
      summary: Update a product
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductWrite" }
      responses:
        "200":
          description: The updated product.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Product" }
    delete:
      tags: [Products]
      summary: Delete a product
      responses:
        "200":
          description: Deleted.

  /order/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, description: "Order `uniq_id`." }
    get:
      tags: [Orders]
      summary: Retrieve an order
      responses:
        "200":
          description: The order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Order" }

  /webhooks/endpoints:
    post:
      tags: [Webhooks]
      summary: Register a webhook endpoint
      description: |
        Registers a URL to receive signed webhook events. The endpoint's
        secret is generated server-side (32 chars) but **not returned in
        this response**. Read it from the admin UI under
        Developers → Webhooks. (Filed for fix.)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
      responses:
        "200":
          description: Endpoint created.
        "422":
          description: Validation failed (URL invalid or already registered).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-API-KEY
      description: |
        Per-account API key. Issue from **Developers → API tokens** in the
        admin UI. Tokens are scoped to a single account; the URL `{account}`
        must match.

  parameters:
    Page:
      in: query
      name: page
      schema: { type: integer, minimum: 1, default: 1 }
    SubscriptionId:
      in: path
      name: id
      required: true
      schema: { type: string, description: "Subscription `uniq_id`." }

  responses:
    Unauthorized:
      description: Missing or invalid `X-API-KEY`.
      content:
        application/json:
          schema:
            nullable: true
    NotFound:
      description: Resource not found, or token does not belong to the URL account.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Pagination:
      type: object
      properties:
        total: { type: integer }
        count: { type: integer }
        per_page: { type: integer }
        current_page: { type: integer }
        total_pages: { type: integer }
        links:
          type: object
          properties:
            next: { type: string, nullable: true }
            previous: { type: string, nullable: true }

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }
      required: [error]

    SubscriptionStatus:
      type: string
      enum:
        - pending
        - future_start
        - trialing
        - active
        - delayed
        - unpaid
        - cancelled
        - expired
      description: |
        Lifecycle:
        - `pending`: created, payment not yet received.
        - `future_start`: has a `starts_at` in the future.
        - `trialing`: in a trial period.
        - `active`: paid, currently working.
        - `delayed`: `renews_at` in the past, retrying.
        - `unpaid`: renewal attempts failed; needs admin.
        - `cancelled`: cancelled (may stay active until `ends_at`).
        - `expired`: reached the end of a fixed-cycle product.

    OrderState:
      type: string
      enum:
        - created
        - confirmed
        - payment_pending_user
        - payment_pending
        - payment_failed
        - payment_cancelled
        - payment_refunded
        - paid

    CustomFieldValue:
      type: object
      properties:
        id: { type: integer }
        label: { type: string }
        type: { type: string }
        value: {}

    Subscription:
      type: object
      properties:
        id: { type: string, description: "Subscription `uniq_id`." }
        price: { type: integer, description: "Amount in cents." }
        vat: { type: integer, description: "VAT in basis points." }
        currency: { type: string, example: SEK }
        created_at: { type: string, format: date-time }
        renews_at: { type: string, format: date-time, nullable: true }
        ends_at: { type: string, format: date-time, nullable: true }
        trial_ends_at: { type: string, format: date-time, nullable: true }
        reference_id: { type: string, nullable: true }
        reference_name: { type: string, nullable: true }
        status: { $ref: "#/components/schemas/SubscriptionStatus" }
        update_card_url: { type: string, format: uri, description: "Signed billing-portal URL." }
        invoices_url: { type: string, format: uri, description: "Signed billing-portal URL." }
        cancel_url: { type: string, format: uri, description: "Signed billing-portal URL." }
        custom_fields:
          type: array
          items: { $ref: "#/components/schemas/CustomFieldValue" }
        metadata:
          type: object
          additionalProperties: true

    SubscriptionList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Subscription" }
        pagination: { $ref: "#/components/schemas/Pagination" }

    Order:
      type: object
      properties:
        id: { type: string, description: "Order `uniq_id`." }
        created_at: { type: string, format: date-time }
        total: { type: integer, description: "Subtotal in cents." }
        total_with_vat: { type: integer, description: "Total incl. VAT in cents." }
        currency: { type: string }
        reference_id: { type: string, nullable: true }
        reference_name: { type: string, nullable: true }
        state: { $ref: "#/components/schemas/OrderState" }
        remote_id:
          type: string
          nullable: true
          description: Billing-method's external ID (Stripe charge ID, Swish payment ID, etc.).
        delivery:
          type: object
          nullable: true
          properties:
            link: { type: string, format: uri }
        custom_fields:
          type: array
          items: { $ref: "#/components/schemas/CustomFieldValue" }
        metadata:
          type: object
          additionalProperties: true

    OrderList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Order" }
        pagination: { $ref: "#/components/schemas/Pagination" }

    Product:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        description: { type: string, nullable: true }
        type:
          type: string
          description: Reflection short-name of the product type class.
          example: Recurring
        price:
          type: integer
          description: |
            Amount in cents. Whether this is "with VAT" or "without VAT"
            depends on the product's `show_price_including_vat` parameter.
            Use `price_with_vat` / `price_without_vat` if you don't want to
            check.
        price_with_vat: { type: integer }
        price_without_vat: { type: integer }
        currency: { type: string }
        vat_rate: { type: number, description: "VAT rate in percent (e.g. 25)." }
        interval: { type: string, nullable: true }
        image: { type: string, format: uri, nullable: true }
        images:
          type: array
          items:
            type: object
            properties:
              portrait:
                type: object
                properties: { src: { type: string, format: uri } }
              square:
                type: object
                properties: { src: { type: string, format: uri } }
        use_cart: { type: boolean }
        stock_tracking: { type: boolean }
        out_of_stock: { type: boolean }
        strings:
          type: object
          properties:
            interval: { type: string }
            type: { type: string }
            price: { type: string, nullable: true }
            price_with_vat: { type: string, nullable: true }
            price_without_vat: { type: string, nullable: true }

    ProductList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Product" }
        pagination: { $ref: "#/components/schemas/Pagination" }

    ProductWrite:
      type: object
      description: Payload for `POST /product/{id}` (update). Only sent fields are updated.
      additionalProperties: true
      properties:
        name: { type: string }
        description: { type: string }
        price: { type: integer }
        currency: { type: string }
        vat_rate: { type: number }
        interval: { type: string }
