openapi: 3.0.3

info:
  title: Bilify REST API
  version: "1.0.0"
  x-api-version: v1
  description: >
    The public REST API for Bilify, the multi-tenant invoicing and billing
    platform. Every endpoint operates inside the workspace (company) pinned to
    the API key that authenticates the request, acting as the user who created
    that key, with exactly the permissions that user has in the dashboard.


    ## Authentication


    Send a Stripe-style opaque key as a bearer token:


    ```
    Authorization: Bearer blf_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    ```


    Keys are created and revoked in the dashboard under
    Settings > Integrations. They are shown exactly once at creation and stored
    hashed. Two environments exist, distinguished by prefix:


    - `blf_live_` binds the real production workspace.

    - `blf_test_` binds the separate sandbox workspace (test data only).


    A missing, malformed, unknown, revoked, or expired key returns a single
    generic `401` (`invalid_api_key` / `unauthenticated`) that never reveals
    which part was wrong. If the workspace plan does not include `api_access`,
    or the key creator has lost workspace membership, the key is inert and the
    request is rejected with the standard error envelope.


    ## Conventions


    - Resources are addressed by their public ULID `reference`, never a numeric
      id.

    - Field names are snake_case. Dates are ISO 8601 (`YYYY-MM-DD` for calendar
      dates, RFC 3339 timestamps for instants).

    - Monetary amounts appear twice: a decimal string in the major unit
      (`total`) and an integer of minor units (`total_cents`), always with a
      currency code. Compute against `_cents`; display the string.

    - Successful responses wrap a single resource in `data`, and list responses
      wrap the array in `data` alongside a `meta` pagination block.


    ## Pagination


    List endpoints accept `page` and `per_page` (capped at 100, default 25) and
    return a `meta` block with `page`, `per_page`, `total`, and `last_page`.


    ## Errors


    Every failure uses one envelope with a stable machine `code`, a localized
    human `message`, and, for validation failures only, an `errors` map of
    field to messages:


    ```json
    {
      "error": {
        "code": "validation_failed",
        "message": "The given data was invalid.",
        "errors": { "name": ["The name field is required."] }
      }
    }
    ```


    Stable codes: `validation_failed` (422), `unauthenticated` /
    `invalid_api_key` (401), `forbidden` (403), `not_found` (404),
    `rate_limited` (429), `plan_limit_reached` (402), `immutable_document`
    (409), `document_not_payable` (409), `idempotency_conflict` (409),
    `server_error` (500).


    ## Idempotency


    Write `POST` endpoints accept an `Idempotency-Key` request header. The first
    successful response for a key is stored (per workspace) for 24 hours and
    replayed byte-for-byte on an identical retry, so a retried create never
    double-creates. Reusing a key with a different request body is rejected with
    `idempotency_conflict`. A replayed response carries `Idempotency-Replayed:
    true`. Idempotency is opt-in: omit the header and the request runs normally.


    ## Rate limiting


    Requests are limited per API key (default 120 per minute). Every response
    carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`; a throttled request
    returns `429` (`rate_limited`) with a `Retry-After` header.


    ## Webhooks


    Bilify also delivers outbound, HMAC-signed webhooks on domain events. They
    are not part of the request/response surface below; see the `x-webhooks`
    section and the developer docs page for the event catalog, payload envelope,
    and the `X-Bilify-Signature` verification scheme.
  contact:
    name: Bilify
    url: https://bilify.ddev.site
  license:
    name: Proprietary

servers:
  - url: https://bilify.ddev.site/api/v1
    description: Local DDEV environment
  - url: https://{host}/api/v1
    description: Deployed environment
    variables:
      host:
        default: bilify.ddev.site
        description: The host serving the workspace.

security:
  - bearerAuth: []

tags:
  - name: Clients
    description: The workspace's customers.
  - name: Documents
    description: >
      Invoices and other document types. A document is created as a numberless
      draft, edited freely, then issued as a deliberate step that assigns its
      number exactly once and freezes it. Only drafts are editable or deletable.
  - name: Items
    description: The catalog of products and services. Stock is read-only in v1.
  - name: Expenses
    description: Recorded business expenses.
  - name: Statistics
    description: >
      Read-only aggregates powered by the same service as the dashboard:
      revenue/expense summary, outstanding receivables, top clients, and
      monthly trends.
  - name: Webhooks
    description: Management of outbound webhook deliveries.

paths:

  /clients:
    get:
      tags: [Clients]
      operationId: listClients
      summary: List clients
      description: >
        Returns a paginated list of client summaries ordered by name. Requires
        the `clients.view` permission.
      parameters:
        - $ref: '#/components/parameters/ClientType'
        - $ref: '#/components/parameters/Search'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: A page of clients.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ClientSummary' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Clients]
      operationId: createClient
      summary: Create a client
      description: Creates a client. Requires the `clients.create` permission.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClientCreate' }
      responses:
        '201':
          description: The created client.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Client' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /clients/{ulid}:
    parameters:
      - $ref: '#/components/parameters/Ulid'
    get:
      tags: [Clients]
      operationId: getClient
      summary: Retrieve a client
      description: Returns one client by reference. Requires the `clients.view` permission.
      responses:
        '200':
          description: The client.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Client' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Clients]
      operationId: updateClient
      summary: Update a client
      description: >
        Partially updates a client; omitted fields are left untouched. Requires
        the `clients.update` permission.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClientUpdate' }
      responses:
        '200':
          description: The updated client.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Client' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /documents:
    get:
      tags: [Documents]
      operationId: listDocuments
      summary: List documents
      description: >
        Returns a paginated list of document summaries, newest issue date first.
        Requires the `documents.view` permission.
      parameters:
        - $ref: '#/components/parameters/DocumentStatus'
        - name: client
          in: query
          description: Filter by client reference (ULID).
          required: false
          schema: { type: string }
        - $ref: '#/components/parameters/From'
        - $ref: '#/components/parameters/To'
        - $ref: '#/components/parameters/Search'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: A page of documents.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/DocumentSummary' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Documents]
      operationId: createDocument
      summary: Create a draft document
      description: >
        Creates a numberless draft document. Requires the `documents.create`
        permission. The document must be issued separately to receive its
        number.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DocumentCreate' }
      responses:
        '201':
          description: The created draft document.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Document' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /documents/{ulid}:
    parameters:
      - $ref: '#/components/parameters/Ulid'
    get:
      tags: [Documents]
      operationId: getDocument
      summary: Retrieve a document
      description: >
        Returns one document with its header, totals, tax breakdown, lines, and
        payments. Requires the `documents.view` permission.
      responses:
        '200':
          description: The document.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Document' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Documents]
      operationId: updateDocument
      summary: Update a draft document
      description: >
        Partially updates a draft document. Requires the `documents.update`
        permission. An already-issued document is immutable and returns
        `immutable_document` (409).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DocumentUpdate' }
      responses:
        '200':
          description: The updated draft document.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Document' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/ImmutableDocument' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Documents]
      operationId: deleteDocument
      summary: Delete a draft document
      description: >
        Deletes a draft document. Requires the `documents.delete` permission. An
        issued document keeps its number for audit continuity and is cancelled,
        never deleted, so it returns `immutable_document` (409).
      responses:
        '204':
          description: The draft document was deleted.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/ImmutableDocument' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /documents/{ulid}/issue:
    parameters:
      - $ref: '#/components/parameters/Ulid'
    post:
      tags: [Documents]
      operationId: issueDocument
      summary: Issue a document
      description: >
        Issues a draft document: assigns its number through the numbering
        service exactly once and freezes it. Requires the `documents.issue`
        permission. Issuing beyond the plan's monthly document limit is refused
        with `plan_limit_reached` (402). Re-issuing an already-issued document
        is a harmless no-op.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DocumentIssue' }
      responses:
        '200':
          description: The issued document.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Document' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PlanLimitReached' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /documents/{ulid}/payments:
    parameters:
      - $ref: '#/components/parameters/Ulid'
    post:
      tags: [Documents]
      operationId: recordDocumentPayment
      summary: Record a payment
      description: >
        Records a payment against an issued document and returns the refreshed
        document. Requires the `documents.update` permission. A draft or
        cancelled document cannot take a payment and returns
        `document_not_payable` (409).
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DocumentPaymentCreate' }
      responses:
        '201':
          description: The document after the payment was applied.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Document' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/DocumentNotPayable' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /items:
    get:
      tags: [Items]
      operationId: listItems
      summary: List catalog items
      description: >
        Returns a paginated list of item summaries ordered by name. Requires the
        `items.view` permission.
      parameters:
        - $ref: '#/components/parameters/ItemType'
        - name: active
          in: query
          description: Filter by active state.
          required: false
          schema: { type: boolean }
        - $ref: '#/components/parameters/Search'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: A page of items.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ItemSummary' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Items]
      operationId: createItem
      summary: Create a catalog item
      description: >
        Creates a product or service. Requires the `items.create` permission.
        `initial_stock` and `initial_stock_note` apply to products only.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ItemCreate' }
      responses:
        '201':
          description: The created item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /items/{ulid}:
    parameters:
      - $ref: '#/components/parameters/Ulid'
    get:
      tags: [Items]
      operationId: getItem
      summary: Retrieve a catalog item
      description: Returns one item by reference. Requires the `items.view` permission.
      responses:
        '200':
          description: The item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Items]
      operationId: updateItem
      summary: Update a catalog item
      description: >
        Partially updates an item; omitted fields are left untouched. Requires
        the `items.update` permission. The item type and stock are fixed at
        creation and cannot be changed here.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ItemUpdate' }
      responses:
        '200':
          description: The updated item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /expenses:
    get:
      tags: [Expenses]
      operationId: listExpenses
      summary: List expenses
      description: >
        Returns a paginated list of expense summaries, newest occurrence first.
        Requires the `expenses.view` permission.
      parameters:
        - $ref: '#/components/parameters/From'
        - $ref: '#/components/parameters/To'
        - $ref: '#/components/parameters/Search'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: A page of expenses.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ExpenseSummary' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Expenses]
      operationId: createExpense
      summary: Create an expense
      description: Creates an expense. Requires the `expenses.create` permission.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExpenseCreate' }
      responses:
        '201':
          description: The created expense.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Expense' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /expenses/{ulid}:
    parameters:
      - $ref: '#/components/parameters/Ulid'
    get:
      tags: [Expenses]
      operationId: getExpense
      summary: Retrieve an expense
      description: Returns one expense by reference. Requires the `expenses.view` permission.
      responses:
        '200':
          description: The expense.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Expense' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Expenses]
      operationId: updateExpense
      summary: Update an expense
      description: >
        Updates an expense. Requires the `expenses.update` permission. An expense
        generated from a recurring template keeps its scheduled date and records
        the change as an override.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExpenseUpdate' }
      responses:
        '200':
          description: The updated expense.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Expense' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Expenses]
      operationId: deleteExpense
      summary: Delete an expense
      description: Deletes an expense. Requires the `expenses.delete` permission.
      responses:
        '204':
          description: The expense was deleted.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /statistics/summary:
    get:
      tags: [Statistics]
      operationId: getStatisticsSummary
      summary: Revenue and expense summary
      description: >
        Revenue, expenses, and net for a date range on a cash basis. Requires
        the `reports.view` permission.
      parameters:
        - $ref: '#/components/parameters/From'
        - $ref: '#/components/parameters/To'
      responses:
        '200':
          description: The summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/BusinessStatistics' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /statistics/outstanding:
    get:
      tags: [Statistics]
      operationId: getStatisticsOutstanding
      summary: Outstanding receivables
      description: >
        The receivables position with its aging breakdown at a point in time.
        Requires the `reports.view` permission.
      parameters:
        - name: as_of
          in: query
          description: The point in time to age against (defaults to now).
          required: false
          schema: { type: string, format: date }
      responses:
        '200':
          description: The outstanding position.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/OutstandingStatistics' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /statistics/top-clients:
    get:
      tags: [Statistics]
      operationId: getStatisticsTopClients
      summary: Top clients by revenue
      description: >
        Clients ranked by revenue received over a range. Requires the
        `reports.view` permission.
      parameters:
        - name: limit
          in: query
          description: Maximum number of clients to return.
          required: false
          schema: { type: integer, minimum: 1, maximum: 50, default: 5 }
        - $ref: '#/components/parameters/From'
        - $ref: '#/components/parameters/To'
      responses:
        '200':
          description: The ranked clients.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TopClient' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /statistics/trends:
    get:
      tags: [Statistics]
      operationId: getStatisticsTrends
      summary: Monthly revenue and expense trend
      description: >
        Revenue, expenses, and net per month for the trailing window. Requires
        the `reports.view` permission.
      parameters:
        - name: months
          in: query
          description: Number of trailing months to include.
          required: false
          schema: { type: integer, minimum: 1, maximum: 36, default: 6 }
      responses:
        '200':
          description: The monthly trend points.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/MonthlyTrend' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhook-deliveries/{ulid}/redeliver:
    parameters:
      - $ref: '#/components/parameters/Ulid'
    post:
      tags: [Webhooks]
      operationId: redeliverWebhookDelivery
      summary: Redeliver a webhook delivery
      description: >
        Resets a webhook delivery to pending and re-queues it for another
        attempt. Requires the `integrations.manage` permission.
      responses:
        '202':
          description: The delivery was queued for redelivery.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      reference: { type: string, description: The delivery reference (ULID). }
                      status: { type: string, example: pending }
                      redelivered: { type: boolean, example: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: blf_live_ / blf_test_
      description: >
        A Bilify API key sent as `Authorization: Bearer blf_live_...` (production)
        or `blf_test_...` (sandbox). Created and revoked under Settings >
        Integrations; shown once at creation.

  parameters:
    Ulid:
      name: ulid
      in: path
      required: true
      description: The resource's public ULID reference.
      schema: { type: string }
    Page:
      name: page
      in: query
      required: false
      description: The 1-based page number.
      schema: { type: integer, minimum: 1, default: 1 }
    PerPage:
      name: per_page
      in: query
      required: false
      description: Items per page, capped at 100.
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    Search:
      name: search
      in: query
      required: false
      description: Free-text search filter.
      schema: { type: string }
    From:
      name: from
      in: query
      required: false
      description: Inclusive lower date bound (YYYY-MM-DD).
      schema: { type: string, format: date }
    To:
      name: to
      in: query
      required: false
      description: Inclusive upper date bound (YYYY-MM-DD).
      schema: { type: string, format: date }
    ClientType:
      name: type
      in: query
      required: false
      description: Filter by client type.
      schema: { type: string, enum: [business, individual] }
    ItemType:
      name: type
      in: query
      required: false
      description: Filter by item type.
      schema: { type: string, enum: [product, service] }
    DocumentStatus:
      name: status
      in: query
      required: false
      description: Filter by document status.
      schema:
        type: string
        enum: [draft, issued, sent, viewed, partially_paid, paid, overdue, cancelled]
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: >
        Opt-in idempotency token. The first successful response is stored for
        24h and replayed on an identical retry; reuse with a different body
        returns `idempotency_conflict`.
      schema: { type: string }

  headers:
    RateLimitLimit:
      description: The request ceiling for the current window.
      schema: { type: integer }
    RateLimitRemaining:
      description: Remaining requests in the current window.
      schema: { type: integer }
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer }

  responses:
    Unauthorized:
      description: Missing, malformed, unknown, revoked, or expired API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { code: unauthenticated, message: The request was not authenticated. }
    Forbidden:
      description: The acting user lacks the permission this endpoint requires.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { code: forbidden, message: You are not allowed to perform this action. }
    NotFound:
      description: No such resource in the bound workspace.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { code: not_found, message: The requested resource was not found. }
    ValidationError:
      description: The request failed validation. `errors` maps each field to its messages.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: validation_failed
              message: The given data was invalid.
              errors: { name: [The name field is required.] }
    RateLimited:
      description: The per-key rate limit was exceeded.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { code: rate_limited, message: Too many requests. }
    ImmutableDocument:
      description: The document is issued and can no longer be edited or deleted.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { code: immutable_document, message: This document has been issued and can no longer be changed. }
    DocumentNotPayable:
      description: A payment was attempted against a draft or cancelled document.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { code: document_not_payable, message: Only an issued document can accept a payment. }
    PlanLimitReached:
      description: Issuing would exceed the plan's monthly document limit.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { code: plan_limit_reached, message: Your plan limit has been reached. }
    IdempotencyConflict:
      description: The Idempotency-Key was reused with a different request body.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error: { code: idempotency_conflict, message: This idempotency key was already used with a different request. }

  schemas:

    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: A stable machine-readable error code.
              enum:
                - validation_failed
                - unauthenticated
                - invalid_api_key
                - forbidden
                - not_found
                - rate_limited
                - plan_limit_reached
                - immutable_document
                - document_not_payable
                - idempotency_conflict
                - bad_request
                - method_not_allowed
                - conflict
                - server_error
            message:
              type: string
              description: A localized human-readable message.
            errors:
              type: object
              nullable: true
              description: Present only for validation_failed; maps a field to its messages.
              additionalProperties:
                type: array
                items: { type: string }

    PaginationMeta:
      type: object
      properties:
        page: { type: integer, example: 1 }
        per_page: { type: integer, example: 25 }
        total: { type: integer, example: 137 }
        last_page: { type: integer, example: 6 }

    Money:
      type: string
      description: A decimal amount in the major currency unit, as a string.
      example: "1250.00"

    ClientSummary:
      type: object
      properties:
        reference: { type: string, description: Public ULID. }
        name: { type: string }
        type: { type: string, enum: [business, individual], nullable: true }
        tax_number: { type: string, nullable: true }
        email: { type: string, nullable: true }
        city: { type: string, nullable: true }
        default_currency: { type: string, nullable: true, example: MKD }

    Client:
      type: object
      properties:
        reference: { type: string, description: Public ULID. }
        name: { type: string }
        legal_name: { type: string, nullable: true }
        type: { type: string, enum: [business, individual], nullable: true }
        tax_number: { type: string, nullable: true }
        registration_number: { type: string, nullable: true }
        email: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        address_line1: { type: string, nullable: true }
        address_line2: { type: string, nullable: true }
        city: { type: string, nullable: true }
        postal_code: { type: string, nullable: true }
        country: { type: string, nullable: true, description: ISO 3166-1 alpha-2 code., example: MK }
        default_currency: { type: string, nullable: true, example: MKD }
        portal_access_enabled: { type: boolean }
        notes: { type: string, nullable: true }
        created_at: { type: string, format: date-time, nullable: true }

    ClientCreate:
      type: object
      required: [name]
      properties:
        type: { type: string, enum: [business, individual], description: Defaults to business. }
        name: { type: string, maxLength: 190 }
        legal_name: { type: string, nullable: true, maxLength: 190 }
        tax_number: { type: string, nullable: true, maxLength: 40, description: Unique within the workspace. }
        registration_number: { type: string, nullable: true, maxLength: 40 }
        email: { type: string, format: email, nullable: true, maxLength: 190, description: Unique within the workspace. }
        phone: { type: string, nullable: true, maxLength: 40 }
        address_line1: { type: string, nullable: true, maxLength: 190 }
        address_line2: { type: string, nullable: true, maxLength: 190 }
        city: { type: string, nullable: true, maxLength: 120 }
        postal_code: { type: string, nullable: true, maxLength: 20 }
        country: { type: string, nullable: true, minLength: 2, maxLength: 2, description: ISO 3166-1 alpha-2 code. }
        default_currency: { type: string, nullable: true, minLength: 3, maxLength: 3 }
        portal_access_enabled: { type: boolean }
        notes: { type: string, nullable: true, maxLength: 2000 }

    ClientUpdate:
      type: object
      description: All fields optional; omitted fields are left untouched.
      properties:
        type: { type: string, enum: [business, individual] }
        name: { type: string, maxLength: 190 }
        legal_name: { type: string, nullable: true, maxLength: 190 }
        tax_number: { type: string, nullable: true, maxLength: 40 }
        registration_number: { type: string, nullable: true, maxLength: 40 }
        email: { type: string, format: email, nullable: true, maxLength: 190 }
        phone: { type: string, nullable: true, maxLength: 40 }
        address_line1: { type: string, nullable: true, maxLength: 190 }
        address_line2: { type: string, nullable: true, maxLength: 190 }
        city: { type: string, nullable: true, maxLength: 120 }
        postal_code: { type: string, nullable: true, maxLength: 20 }
        country: { type: string, nullable: true, minLength: 2, maxLength: 2 }
        default_currency: { type: string, nullable: true, minLength: 3, maxLength: 3 }
        portal_access_enabled: { type: boolean }
        notes: { type: string, nullable: true, maxLength: 2000 }

    ClientRef:
      type: object
      nullable: true
      properties:
        reference: { type: string }
        name: { type: string }

    DocumentSummary:
      type: object
      properties:
        reference: { type: string, description: Public ULID. }
        number: { type: string, nullable: true, description: Null until issued. }
        status:
          type: string
          nullable: true
          enum: [draft, issued, sent, viewed, partially_paid, paid, overdue, cancelled]
        type: { type: string, nullable: true, description: Document type key. }
        type_name: { type: string, nullable: true }
        client: { $ref: '#/components/schemas/ClientRef' }
        issue_date: { type: string, format: date, nullable: true }
        due_date: { type: string, format: date, nullable: true }
        currency: { type: string, example: MKD }
        total: { $ref: '#/components/schemas/Money' }
        total_cents: { type: integer }
        paid: { $ref: '#/components/schemas/Money' }
        paid_cents: { type: integer }
        balance: { $ref: '#/components/schemas/Money' }
        balance_cents: { type: integer }

    Document:
      type: object
      properties:
        reference: { type: string, description: Public ULID. }
        number: { type: string, nullable: true }
        status:
          type: string
          nullable: true
          enum: [draft, issued, sent, viewed, partially_paid, paid, overdue, cancelled]
        type: { type: string, nullable: true, description: Document type key. }
        type_name: { type: string, nullable: true }
        client: { $ref: '#/components/schemas/ClientRef' }
        currency: { type: string, example: MKD }
        issue_date: { type: string, format: date, nullable: true }
        due_date: { type: string, format: date, nullable: true }
        delivery_date: { type: string, format: date, nullable: true }
        issued_at: { type: string, format: date-time, nullable: true }
        sent_at: { type: string, format: date-time, nullable: true }
        subtotal: { $ref: '#/components/schemas/Money' }
        subtotal_cents: { type: integer }
        tax_total: { $ref: '#/components/schemas/Money' }
        tax_total_cents: { type: integer }
        total: { $ref: '#/components/schemas/Money' }
        total_cents: { type: integer }
        paid: { $ref: '#/components/schemas/Money' }
        paid_cents: { type: integer }
        balance: { $ref: '#/components/schemas/Money' }
        balance_cents: { type: integer }
        tax_breakdown:
          type: array
          description: Frozen tax bands at issue.
          items:
            type: object
            properties:
              rate_key: { type: string }
              rate_percent: { type: string }
              base_cents: { type: integer }
              tax_cents: { type: integer }
        lines:
          type: array
          items: { $ref: '#/components/schemas/DocumentLine' }
        payments:
          type: array
          items: { $ref: '#/components/schemas/DocumentPayment' }

    DocumentLine:
      type: object
      properties:
        position: { type: integer }
        description: { type: string, nullable: true }
        item_reference: { type: string, nullable: true, description: "Catalog item ULID, if linked." }
        quantity: { type: string }
        unit_price: { $ref: '#/components/schemas/Money' }
        unit_price_cents: { type: integer }
        tax_rate: { type: string, nullable: true, description: Snapshot tax percentage. }
        line_subtotal: { $ref: '#/components/schemas/Money' }
        line_tax: { $ref: '#/components/schemas/Money' }
        line_total: { $ref: '#/components/schemas/Money' }
        line_total_cents: { type: integer }

    DocumentPayment:
      type: object
      properties:
        amount: { $ref: '#/components/schemas/Money' }
        amount_cents: { type: integer }
        currency: { type: string }
        method: { type: string, enum: [bank, cash, card, gateway], nullable: true }
        paid_at: { type: string, format: date-time, nullable: true }

    DocumentLineInput:
      type: object
      required: [quantity, unit_price]
      properties:
        description: { type: string, nullable: true, maxLength: 1000 }
        unit: { type: string, nullable: true, maxLength: 40 }
        quantity: { type: number, minimum: 0.001 }
        unit_price: { type: number, minimum: 0, description: Major-unit price. }
        item: { type: string, nullable: true, description: Catalog item ULID to link. }
        tax_rate: { type: string, nullable: true, description: Tax rate key for the workspace country. }
        discount_type: { type: string, nullable: true, enum: [percent, fixed] }
        discount_value: { type: number, nullable: true, minimum: 0 }

    DocumentCreate:
      type: object
      required: [document_type, client, lines]
      properties:
        document_type: { type: string, description: Active document type key for the workspace country. }
        client: { type: string, description: Client ULID. }
        currency: { type: string, nullable: true, minLength: 3, maxLength: 3, description: "Defaults to the client's, then the workspace's currency." }
        issue_date: { type: string, format: date, nullable: true }
        due_date: { type: string, format: date, nullable: true }
        delivery_date: { type: string, format: date, nullable: true }
        lines:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/DocumentLineInput' }

    DocumentUpdate:
      type: object
      description: Drafts only; all fields optional. Sending `lines` replaces the whole set.
      properties:
        document_type: { type: string }
        client: { type: string, description: Client ULID. }
        currency: { type: string, nullable: true, minLength: 3, maxLength: 3 }
        issue_date: { type: string, format: date, nullable: true }
        due_date: { type: string, format: date, nullable: true }
        delivery_date: { type: string, format: date, nullable: true }
        lines:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/DocumentLineInput' }

    DocumentIssue:
      type: object
      properties:
        issue_date: { type: string, format: date, nullable: true, description: Overrides the issue date at issue time. }

    DocumentPaymentCreate:
      type: object
      required: [amount, method, paid_on]
      properties:
        amount: { type: number, minimum: 0.01, description: Major-unit amount. }
        method: { type: string, enum: [bank, cash, card, gateway] }
        paid_on: { type: string, format: date }

    ItemSummary:
      type: object
      properties:
        reference: { type: string, description: Public ULID. }
        name: { type: string }
        sku: { type: string, nullable: true }
        type: { type: string, enum: [service, product], nullable: true }
        unit: { type: string }
        category: { type: string, nullable: true }
        default_price: { $ref: '#/components/schemas/Money' }
        default_price_cents: { type: integer }
        currency: { type: string }
        active: { type: boolean }
        stock_quantity: { type: string, nullable: true, description: Products only. }
        is_low_stock: { type: boolean }

    Item:
      type: object
      properties:
        reference: { type: string, description: Public ULID. }
        name: { type: string }
        description: { type: string, nullable: true }
        sku: { type: string, nullable: true }
        type: { type: string, enum: [service, product], nullable: true }
        unit: { type: string }
        category: { type: string, nullable: true }
        default_price: { $ref: '#/components/schemas/Money' }
        default_price_cents: { type: integer }
        currency: { type: string }
        default_tax_rate: { type: string, nullable: true, description: "Percentage, as a string." }
        active: { type: boolean }
        stock_quantity: { type: string, nullable: true }
        low_stock_threshold: { type: string, nullable: true }
        is_low_stock: { type: boolean }
        created_at: { type: string, format: date-time, nullable: true }

    ItemCreate:
      type: object
      required: [type, name, default_price]
      properties:
        type: { type: string, enum: [product, service], description: Fixed at creation. }
        name: { type: string, maxLength: 190 }
        description: { type: string, nullable: true }
        sku: { type: string, nullable: true, maxLength: 100, description: Unique within the workspace. }
        unit: { type: string, maxLength: 40, description: Defaults to "pcs". }
        default_price: { type: number, minimum: 0, description: Major-unit price. }
        currency: { type: string, nullable: true, minLength: 3, maxLength: 3 }
        tax_rate: { type: string, nullable: true, description: Tax rate key for the workspace country. }
        low_stock_threshold: { type: number, nullable: true, minimum: 0, description: Products only. }
        active: { type: boolean, description: Defaults to true. }
        initial_stock: { type: number, nullable: true, minimum: 0, description: Products only; opening stock quantity. }
        initial_stock_note: { type: string, nullable: true, maxLength: 255 }

    ItemUpdate:
      type: object
      description: All fields optional. Type and stock cannot be changed here.
      properties:
        name: { type: string, maxLength: 190 }
        description: { type: string, nullable: true }
        sku: { type: string, nullable: true, maxLength: 100 }
        unit: { type: string, maxLength: 40 }
        default_price: { type: number, minimum: 0 }
        currency: { type: string, nullable: true, minLength: 3, maxLength: 3 }
        tax_rate: { type: string, nullable: true }
        low_stock_threshold: { type: number, nullable: true, minimum: 0 }
        active: { type: boolean }

    ExpenseSummary:
      type: object
      properties:
        reference: { type: string, description: Public ULID. }
        description: { type: string }
        vendor: { type: string, nullable: true }
        category: { type: string, nullable: true }
        occurs_on: { type: string, format: date, nullable: true }
        amount: { $ref: '#/components/schemas/Money' }
        amount_cents: { type: integer }
        currency: { type: string }
        is_recurring: { type: boolean }

    Expense:
      type: object
      properties:
        reference: { type: string, description: Public ULID. }
        description: { type: string }
        notes: { type: string, nullable: true }
        vendor: { type: string, nullable: true }
        category: { type: string, nullable: true }
        occurs_on: { type: string, format: date, nullable: true }
        amount: { $ref: '#/components/schemas/Money' }
        amount_cents: { type: integer }
        currency: { type: string }
        is_recurring: { type: boolean }
        is_overridden: { type: boolean }
        created_at: { type: string, format: date-time, nullable: true }

    ExpenseCreate:
      type: object
      required: [description, amount, occurs_on]
      properties:
        description: { type: string, maxLength: 190 }
        notes: { type: string, nullable: true, maxLength: 2000 }
        amount: { type: number, minimum: 0, maximum: 999999999.99, description: Major-unit amount. }
        currency: { type: string, nullable: true, minLength: 3, maxLength: 3 }
        vendor: { type: string, nullable: true, maxLength: 190 }
        occurs_on: { type: string, format: date }

    ExpenseUpdate:
      type: object
      description: All fields optional.
      properties:
        description: { type: string, maxLength: 190 }
        notes: { type: string, nullable: true, maxLength: 2000 }
        amount: { type: number, minimum: 0, maximum: 999999999.99 }
        currency: { type: string, nullable: true, minLength: 3, maxLength: 3 }
        vendor: { type: string, nullable: true, maxLength: 190 }
        occurs_on: { type: string, format: date }

    BusinessStatistics:
      type: object
      properties:
        period_start: { type: string, description: ISO date. }
        period_end: { type: string, description: ISO date. }
        currency: { type: string }
        revenue: { $ref: '#/components/schemas/Money' }
        revenue_cents: { type: integer }
        expenses: { $ref: '#/components/schemas/Money' }
        expenses_cents: { type: integer }
        net: { $ref: '#/components/schemas/Money' }
        net_cents: { type: integer }

    OutstandingStatistics:
      type: object
      properties:
        currency: { type: string }
        outstanding: { $ref: '#/components/schemas/Money' }
        outstanding_cents: { type: integer }
        overdue: { $ref: '#/components/schemas/Money' }
        overdue_cents: { type: integer }
        document_count: { type: integer }
        overdue_count: { type: integer }
        aging_buckets:
          type: array
          items: { $ref: '#/components/schemas/AgingBucket' }

    AgingBucket:
      type: object
      properties:
        key: { type: string, example: "31_60" }
        min_days: { type: integer }
        max_days: { type: integer, nullable: true, description: Null means open-ended (e.g. 90+). }
        amount: { $ref: '#/components/schemas/Money' }
        amount_cents: { type: integer }
        document_count: { type: integer }

    TopClient:
      type: object
      properties:
        client_reference: { type: string, description: Client ULID. }
        client_name: { type: string }
        currency: { type: string }
        revenue: { $ref: '#/components/schemas/Money' }
        revenue_cents: { type: integer }
        document_count: { type: integer }

    MonthlyTrend:
      type: object
      properties:
        month: { type: string, description: "YYYY-MM key.", example: "2026-07" }
        period_start: { type: string, description: ISO date. }
        period_end: { type: string, description: ISO date. }
        currency: { type: string }
        revenue: { $ref: '#/components/schemas/Money' }
        revenue_cents: { type: integer }
        expenses: { $ref: '#/components/schemas/Money' }
        expenses_cents: { type: integer }
        net: { $ref: '#/components/schemas/Money' }
        net_cents: { type: integer }

    WebhookEnvelope:
      type: object
      description: >
        The body POSTed to a subscribed webhook endpoint. `data` is the same
        resource representation the matching GET endpoint returns.
      properties:
        event:
          type: string
          enum:
            - client.created
            - client.updated
            - document.created
            - document.issued
            - document.updated
            - document.deleted
            - payment.recorded
            - expense.created
            - expense.updated
            - expense.deleted
        occurred_at: { type: string, format: date-time }
        environment: { type: string, enum: [production, sandbox] }
        data:
          type: object
          description: The subject resource (a Client, Document, or Expense representation).

x-bilify-webhooks:
  description: >
    Outbound webhooks are delivered by Bilify to endpoints you register per
    environment under Settings > Integrations. They are POSTed with a
    WebhookEnvelope body and signed with X-Bilify-Signature.
  payload:
    $ref: '#/components/schemas/WebhookEnvelope'
  signature:
    header: X-Bilify-Signature
    scheme: "t=<unix>,v1=<hex hmac_sha256(secret, t + \".\" + rawBody)>"
    tolerance_seconds: 300
    description: >
      Verify by recomputing HMAC-SHA256 over "<t>.<rawBody>" with the endpoint's
      signing secret, comparing in constant time, then rejecting timestamps
      outside the tolerance window (5 minutes).
  events:
    - name: client.created
      subject: Client
    - name: client.updated
      subject: Client
    - name: document.created
      subject: Document
    - name: document.issued
      subject: Document
    - name: document.updated
      subject: Document
    - name: document.deleted
      subject: Document
    - name: payment.recorded
      subject: Document
    - name: expense.created
      subject: Expense
    - name: expense.updated
      subject: Expense
    - name: expense.deleted
      subject: Expense
  retries:
    schedule_seconds: [60, 300, 1800, 7200, 28800, 86400]
    description: >
      A failed delivery is retried on this backoff (roughly 1m, 5m, 30m, 2h, 8h,
      24h), then marked dead. An endpoint whose recent deliveries all fail is
      auto-disabled and the workspace admins are notified.
