openapi: 3.1.0
info:
  title: Laabam One Public API
  version: 1.0.0
  x-laabam-status: >-
    Recruitment, Lead capture, Storefront, Bookings and Messaging (SMS, WhatsApp
    & Email) are AVAILABLE today, as are Invoices, Customers, Products and
    Payments — READS *and* WRITES. The only PLANNED operation left is PATCH
    /invoices/{company}/{id} (update a draft). Each operation is tagged with
    x-status.
  description: >
    The Laabam One API lets your systems and approved third parties read and
    write

    data in **your company's** Laabam One account — invoices, customers,
    products,

    payments — and send messages over SMS, WhatsApp and email.


    Every request authenticates with two headers — `X-Client-Id` and

    `X-Client-Secret`, created in **Settings → API Keys**. See the

    [Authentication](/docs/authentication) and [Security](/docs/security) guides

    for the full details. Each operation below is marked `x-status: available`

    (callable today) or `x-status: planned` (contract defined, not yet built).
  contact:
    name: Laabam One API Support
    url: https://laabam.one
  license:
    name: Proprietary
servers:
  - url: https://api.laabam.one/v1
    description: Production
security:
  - ClientId: []
    ClientSecret: []
tags:
  - name: Invoices
    description: >-
      ✅ Read, create, send & void available (create posts the Sales JE + moves
      stock; INR). Update (PATCH draft) planned.
  - name: Customers
    description: >-
      ✅ Read, create, update & delete available (create also opens the Sundry
      Debtors ledger account).
  - name: Products
    description: ✅ Read, create, update & stock-adjust available.
  - name: Payments
    description: ✅ Read, record & refund available (record posts the receipt JE; INR only).
  - name: Messaging
    description: >-
      ✅ SMS, WhatsApp & Email available. Security‑sensitive — dedicated send
      scopes, lower rate limits and a daily cap. Path carries the company slug.
  - name: Recruitment
    description: ✅ Available. Public, embeddable career‑site API.
  - name: Leads
    description: ✅ Available. Capture leads from external sites.
  - name: Storefront
    description: ✅ Available. Headless storefront / checkout.
  - name: Bookings
    description: >-
      ✅ Available. Driving-school booking flow — service locations, instructors
      by suburb, lessons & packages, availability, and bookings with online
      payment. Accepts a secret key OR a browser publishable (pk_) key.
paths:
  /invoices/{company}:
    parameters:
      - $ref: '#/components/parameters/Company'
    get:
      tags:
        - Invoices
      operationId: listInvoices
      summary: List invoices
      x-status: available
      x-required-scopes:
        - invoices:read
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          description: >-
            Filters on the invoice's **payment_status** (not the `status` field
            also returned in the response — they are different columns). Voided
            invoices are removed, so they can never appear in this list.
          schema:
            type: string
            enum:
              - unpaid
              - partial
              - paid
        - name: customer_id
          in: query
          schema:
            type: string
      responses:
        '200':
          description: A page of invoices.
          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/Invoice'
                  paging:
                    $ref: '#/components/schemas/Paging'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags:
        - Invoices
      operationId: createInvoice
      summary: Create an invoice
      description: >-
        Creates an invoice, posts the Sales journal entry (DR receivable, CR
        sales + GST split) and deducts stock — the exact same accounting engine
        as the in-app flow. Totals, tax and round-off are computed server-side
        and are authoritative (client-sent totals are ignored). Each line
        requires `item_name`, `quantity`, `unit`, `unit_price` and `tax_rate`.
        INR only in v1 (recurring, attachments and pay-at-create are app-only).
      x-status: available
      x-required-scopes:
        - invoices:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InvoiceCreate'
      responses:
        '201':
          description: Invoice created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Invoice'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /invoices/{company}/{id}:
    parameters:
      - $ref: '#/components/parameters/Company'
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags:
        - Invoices
      operationId: getInvoice
      summary: Get an invoice
      x-status: available
      x-required-scopes:
        - invoices:read
      responses:
        '200':
          description: The invoice.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Invoice'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags:
        - Invoices
      operationId: updateInvoice
      summary: Update a draft invoice
      x-status: planned
      x-required-scopes:
        - invoices:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InvoiceCreate'
      responses:
        '200':
          description: Updated invoice.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Invoice'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
  /invoices/{company}/{id}/void:
    parameters:
      - $ref: '#/components/parameters/Company'
      - $ref: '#/components/parameters/ResourceId'
    post:
      tags:
        - Invoices
      operationId: voidInvoice
      summary: Void an invoice
      description: >-
        Voids (soft-deletes) the invoice and reverses all its side-effects — its
        journal entries, any payments + their JEs, and restores deducted stock —
        the same reversal as the app. Returns a summary of what was reversed.
      x-status: available
      x-required-scopes:
        - invoices:write
      responses:
        '200':
          description: Invoice voided.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      voided:
                        type: boolean
                      reversal:
                        type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /invoices/{company}/{id}/send:
    parameters:
      - $ref: '#/components/parameters/Company'
      - $ref: '#/components/parameters/ResourceId'
    post:
      tags:
        - Invoices
      operationId: sendInvoice
      summary: Email the invoice to the customer
      description: >-
        Renders the invoice PDF (using your configured print template) and
        queues it for email to the customer — the same render the in‑app "Send"
        button uses. Recipients default to the invoice customer's email;
        override with `email` or `emails[]` (max 10). A draft invoice is marked
        as sent. Only `channel: email` is available today; WhatsApp invoice
        delivery is planned.
      x-status: available
      x-required-scopes:
        - invoices:write
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                channel:
                  type: string
                  enum:
                    - email
                  default: email
                email:
                  type: string
                  format: email
                  description: Single recipient (overrides the customer email).
                emails:
                  type: array
                  items:
                    type: string
                    format: email
                  description: Multiple recipients (max 10).
                message:
                  type: string
                  maxLength: 1000
                  description: Optional note included in the email.
      responses:
        '202':
          description: Queued for delivery.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      channel:
                        type: string
                        example: email
                      status:
                        type: string
                        example: queued
                      invoice_id:
                        type: string
                      to:
                        type: array
                        items:
                          type: string
                      queued_count:
                        type: integer
                      created_at:
                        type: string
                        format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /customers/{company}:
    parameters:
      - $ref: '#/components/parameters/Company'
    get:
      tags:
        - Customers
      operationId: listCustomers
      summary: List / search customers
      x-status: available
      x-required-scopes:
        - customers:read
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: q
          in: query
          description: Free‑text search (name / email / phone).
          schema:
            type: string
      responses:
        '200':
          description: A page of customers.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Customer'
                  paging:
                    $ref: '#/components/schemas/Paging'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags:
        - Customers
      operationId: createCustomer
      summary: Create a customer
      description: >-
        Creates a customer and its Sundry Debtors ledger account (same path as
        the UI). `name`, `state` and `pincode` are required. `opening_balance`
        and profile images are NOT settable via the API (the opening-balance
        ledger entry remains a UI-only action).
      x-status: available
      x-required-scopes:
        - customers:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CustomerCreate'
      responses:
        '201':
          description: Customer created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CustomerWriteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
  /customers/{company}/{id}:
    parameters:
      - $ref: '#/components/parameters/Company'
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags:
        - Customers
      operationId: getCustomer
      summary: Get a customer
      x-status: available
      x-required-scopes:
        - customers:read
      responses:
        '200':
          description: The customer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Customer'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags:
        - Customers
      operationId: updateCustomer
      summary: Update a customer
      description: >-
        Partial update — send only the fields you want to change. Renaming keeps
        the linked ledger account names in sync.
      x-status: available
      x-required-scopes:
        - customers:write
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CustomerUpdate'
      responses:
        '200':
          description: Updated customer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/CustomerWriteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
    delete:
      tags:
        - Customers
      operationId: deleteCustomer
      summary: Soft‑delete a customer
      description: >-
        Soft-deletes the customer and removes its debtor account. Blocked with
        `409` if the customer has invoices, payments or posted ledger
        transactions. If the record is also a vendor, only the customer role is
        removed (vendor data is retained).
      x-status: available
      x-required-scopes:
        - customers:write
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      deleted:
                        type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
  /products/{company}:
    parameters:
      - $ref: '#/components/parameters/Company'
    get:
      tags:
        - Products
      operationId: listProducts
      summary: List / search products
      x-status: available
      x-required-scopes:
        - products:read
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: q
          in: query
          schema:
            type: string
        - name: category_id
          in: query
          schema:
            type: string
      responses:
        '200':
          description: A page of products.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Product'
                  paging:
                    $ref: '#/components/schemas/Paging'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags:
        - Products
      operationId: createProduct
      summary: Create a product
      description: >-
        Creates a product. If `track_stock` is true and `opening_stock` > 0, an
        opening stock movement (and a batch when `is_batch_tracked`) is
        recorded. `name` and `unit_id` are required. Images, variants and
        bundles are not settable via the API.
      x-status: available
      x-required-scopes:
        - products:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductCreate'
      responses:
        '201':
          description: Product created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProductWriteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
  /products/{company}/{id}:
    parameters:
      - $ref: '#/components/parameters/Company'
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags:
        - Products
      operationId: getProduct
      summary: Get a product (with current stock)
      x-status: available
      x-required-scopes:
        - products:read
      responses:
        '200':
          description: The product.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Product'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags:
        - Products
      operationId: updateProduct
      summary: Update a product
      description: >-
        Partial update of scalar fields (name, prices, tax/HSN, sku, status, …).
        Stock and `track_stock` are NOT updatable here — use the
        stock-adjustments endpoint to move stock.
      x-status: available
      x-required-scopes:
        - products:write
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductUpdate'
      responses:
        '200':
          description: Updated product.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ProductWriteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
  /products/{company}/{id}/stock-adjustments:
    parameters:
      - $ref: '#/components/parameters/Company'
      - $ref: '#/components/parameters/ResourceId'
    post:
      tags:
        - Products
      operationId: adjustStock
      summary: Adjust stock
      description: >-
        Records a manual stock-adjustment movement and moves the product's
        current stock.
      x-status: available
      x-required-scopes:
        - products:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - adjustment_type
                - quantity
              properties:
                adjustment_type:
                  type: string
                  enum:
                    - increase
                    - decrease
                quantity:
                  type: number
                  minimum: 0.01
                  description: Always positive; direction is set by adjustment_type.
                reason:
                  type: string
                  maxLength: 100
                notes:
                  type: string
                  maxLength: 500
      responses:
        '201':
          description: Stock adjusted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      movement_id:
                        type: string
                      adjustment_type:
                        type: string
                      quantity:
                        type: number
                      current_stock:
                        type: number
                      created_at:
                        type: string
                        format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
  /payments/{company}:
    parameters:
      - $ref: '#/components/parameters/Company'
    get:
      tags:
        - Payments
      operationId: listPayments
      summary: List payments
      x-status: available
      x-required-scopes:
        - payments:read
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: invoice_id
          in: query
          schema:
            type: string
        - name: customer_id
          in: query
          schema:
            type: string
      responses:
        '200':
          description: A page of payments.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Payment'
                  paging:
                    $ref: '#/components/schemas/Paging'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags:
        - Payments
      operationId: recordPayment
      summary: Record a payment against an invoice
      description: >-
        Records a payment and posts the receipt journal entry (DR bank/cash, CR
        the customer's receivable) as the in-app "Record Payment" does, then
        updates the invoice's paid/due/status. `payment_mode_id` chooses the
        bank/cash account — list options via `GET /payments/{company}/methods`.


        **Currency:** only invoices whose `currency` is `INR` or unset are
        accepted; any other value returns 422. This is a hard INR check, *not*
        your company's base currency — a non-INR-base tenant cannot record
        payments against its own foreign-currency invoices via this endpoint.


        **The journal entry is best-effort:** if the cash/bank account, the
        receivable account or the financial year can't be resolved, the payment
        is still recorded and `journal_entry_id` comes back `null`.
      x-status: available
      x-required-scopes:
        - payments:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - invoice_id
                - amount
                - payment_date
                - payment_mode_id
              properties:
                invoice_id:
                  type: string
                amount:
                  type: number
                  minimum: 0.01
                  description: >-
                    `amount` + `tds_amount` together must not exceed the
                    invoice's due amount.
                payment_date:
                  type: string
                  format: date
                payment_mode_id:
                  type: integer
                  description: From GET /payments/{company}/methods.
                tds_amount:
                  type: number
                  minimum: 0
                  description: >-
                    TDS withheld by the buyer. Counts toward settling the
                    invoice alongside `amount`.
                tds_section:
                  type: string
                  maxLength: 40
                reference_number:
                  type: string
                  maxLength: 100
                notes:
                  type: string
                  maxLength: 500
      responses:
        '201':
          description: Payment recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PaymentWriteResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
  /payments/{company}/methods:
    parameters:
      - $ref: '#/components/parameters/Company'
    get:
      tags:
        - Payments
      operationId: listPaymentModes
      summary: List payment modes
      description: >-
        Active payment modes for the company — use the returned `id` as
        `payment_mode_id` when recording a payment.
      x-status: available
      x-required-scopes:
        - payments:read
      responses:
        '200':
          description: Payment modes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        name:
                          type: string
                        mode_type:
                          type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /payments/{company}/{id}:
    parameters:
      - $ref: '#/components/parameters/Company'
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags:
        - Payments
      operationId: getPayment
      summary: Get a payment
      x-status: available
      x-required-scopes:
        - payments:read
      responses:
        '200':
          description: The payment.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Payment'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /payments/{company}/{id}/refund:
    parameters:
      - $ref: '#/components/parameters/Company'
      - $ref: '#/components/parameters/ResourceId'
    post:
      tags:
        - Payments
      operationId: refundPayment
      summary: Refund (void) a payment
      description: >-
        Reverses the payment — voids its journal entry and recomputes the
        invoice's paid/due/status. Full reversal only (partial refunds are not
        supported yet; no request body is read).


        ⚠️ The payment record is **removed**, not flagged. After a refund `GET
        /payments/{company}/{id}` returns **404**, and refunding the same id
        twice also returns 404 — there is no `refunded: true` state to read
        back.
      x-status: available
      x-required-scopes:
        - payments:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Payment reversed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      refunded:
                        type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /messaging/{company}/sms:
    parameters:
      - $ref: '#/components/parameters/Company'
    post:
      tags:
        - Messaging
      operationId: sendSms
      summary: Send an SMS
      description: >-
        Sends an SMS via the company's gateway. Requires the `messages:sms:send`
        scope. Subject to the messaging rate limit + daily cap.


        **India (DLT) — read this before you debug for an afternoon.** Pass an
        approved `sender_id` plus its registered `template_id` (and `pe_id`),
        and make `message` match the approved template text **exactly** —
        including punctuation and any sender suffix (e.g. `... -ACMEID`).
        Variables occupy the `{#var#}` slots. `sender_id` and `template_id` must
        be a *matched pair*: the template is registered against one sender.


        ⚠️ **A mismatch fails silently.** If `message` does not match the
        registered template, or `template_id` is missing/wrong, the carrier
        DROPS the message — but this endpoint still returns **`202`**. It will
        look like it worked while nothing is ever delivered. `202` means
        *accepted*, not *delivered* (lifecycle: queued → submitted → delivered);
        confirm real delivery with `GET /messaging/{company}/messages/{id}`.


        Your approved sender IDs, templates and PE ID are listed in **Marketing
        → SMS Gateway** (DLT Registration / Sender IDs / Templates).
      x-status: available
      x-required-scopes:
        - messages:sms:send
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - to
                - message
              properties:
                to:
                  type: string
                  example: '919800000000'
                  description: Recipient number, digits only (8–15).
                message:
                  type: string
                  maxLength: 1000
                  example: Your OTP is 123456
                sender_id:
                  type: string
                  maxLength: 11
                  description: >-
                    An APPROVED sender ID (Marketing → SMS Gateway → Sender
                    IDs). Must be the sender the `template_id` is registered
                    against.
                  example: ACMEID
                template_id:
                  type: string
                  maxLength: 64
                  description: >-
                    Registered DLT template id. Omit it and Indian carriers drop
                    the SMS silently.
                  example: '1007487766981813506'
                pe_id:
                  type: string
                  maxLength: 64
                  description: >-
                    DLT principal entity (PE) id, from Marketing → SMS Gateway →
                    DLT Registration.
                  example: '1001609937061127455'
                type:
                  type: string
                  enum:
                    - transactional
                    - promotional
                  default: transactional
      responses:
        '202':
          $ref: '#/components/responses/MessageAccepted'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /messaging/{company}/whatsapp:
    parameters:
      - $ref: '#/components/parameters/Company'
    post:
      tags:
        - Messaging
      operationId: sendWhatsApp
      summary: Send a WhatsApp message
      description: >-
        Sends via the company's Meta Cloud API provider. Business‑initiated
        messages must use a Meta‑APPROVED template; free `text` is only
        delivered inside an open 24‑hour customer‑service window. Requires the
        `messages:whatsapp:send` scope.
      x-status: available
      x-required-scopes:
        - messages:whatsapp:send
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - to
              properties:
                to:
                  type: string
                  example: '919800000000'
                type:
                  type: string
                  enum:
                    - template
                    - text
                  default: template
                template:
                  type: object
                  description: Required when type=template.
                  properties:
                    name:
                      type: string
                      example: invoice_due
                    language:
                      type: string
                      example: en
                    variables:
                      type: array
                      items:
                        type: string
                      example:
                        - Acme
                        - '3540.00'
                text:
                  type: object
                  description: Required when type=text.
                  properties:
                    body:
                      type: string
                      example: Thanks for your order!
      responses:
        '202':
          $ref: '#/components/responses/MessageAccepted'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /messaging/{company}/messages/{id}:
    parameters:
      - $ref: '#/components/parameters/Company'
      - name: id
        in: path
        required: true
        description: The provider message id returned by a send call.
        schema:
          type: string
    get:
      tags:
        - Messaging
      operationId: getMessage
      summary: Get message delivery status
      x-status: available
      x-required-scopes:
        - messages:read
      responses:
        '200':
          description: The message.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Message'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /messaging/{company}/email:
    parameters:
      - $ref: '#/components/parameters/Company'
    post:
      tags:
        - Messaging
      operationId: sendEmail
      summary: Send an email
      description: >-
        Sends a transactional email through your company's configured email
        provider (Settings → Marketing → Email provider); if none is set the
        platform default mailer is used. The sender identity is taken from that
        provider — you cannot set an arbitrary `from`. HTML is sanitised
        server‑side (script/iframe/event‑handler vectors are stripped). Up to 50
        recipients per call.
      x-status: available
      x-required-scopes:
        - messages:email:send
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - to
                - subject
                - html
              properties:
                to:
                  description: >-
                    One recipient or an array. Each item may be a plain email
                    string or an object.
                  type: array
                  items:
                    type: object
                    required:
                      - email
                    properties:
                      email:
                        type: string
                        format: email
                      name:
                        type: string
                subject:
                  type: string
                  maxLength: 255
                html:
                  type: string
                  description: Email body as HTML. Sanitised server‑side before send.
      responses:
        '202':
          $ref: '#/components/responses/MessageAccepted'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /recruitment/{company}/jobs:
    parameters:
      - $ref: '#/components/parameters/Company'
    get:
      tags:
        - Recruitment
      operationId: listJobs
      summary: List open jobs
      x-status: available
      x-required-scopes:
        - job_openings.read
      responses:
        '200':
          description: Open jobs.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Job'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /recruitment/{company}/jobs/{slug}:
    parameters:
      - $ref: '#/components/parameters/Company'
      - name: slug
        in: path
        required: true
        schema:
          type: string
    get:
      tags:
        - Recruitment
      operationId: getJob
      summary: Get a job
      x-status: available
      x-required-scopes:
        - job_openings.read
      responses:
        '200':
          description: The job.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Job'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /recruitment/{company}/jobs/{slug}/apply:
    parameters:
      - $ref: '#/components/parameters/Company'
      - name: slug
        in: path
        required: true
        schema:
          type: string
    post:
      tags:
        - Recruitment
      operationId: applyToJob
      summary: Submit an application
      x-status: available
      x-required-scopes:
        - applications.write
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - first_name
                - email
                - resume
              properties:
                first_name:
                  type: string
                  maxLength: 100
                last_name:
                  type: string
                  maxLength: 100
                email:
                  type: string
                  format: email
                  maxLength: 255
                phone:
                  type: string
                  maxLength: 30
                resume:
                  type: string
                  format: binary
                  description: 'Required. Allowed: pdf, doc, docx. Max 10 MB.'
                cover_letter:
                  type: string
                  maxLength: 5000
                total_experience:
                  type: number
                  minimum: 0
                  maximum: 50
                  description: Years.
                expected_salary:
                  type: number
                notice_period_days:
                  type: integer
                  minimum: 0
                linkedin_url:
                  type: string
                  format: uri
                  maxLength: 500
                skills:
                  type: string
                  description: A JSON-encoded string (not an array), e.g. '["PHP","Vue"]'.
      responses:
        '201':
          description: Application received.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            You have already applied for this position. Body carries the
            existing `application_number` and `applied_at`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthError'
        '422':
          $ref: '#/components/responses/ValidationFailed'
  /recruitment/{company}/departments:
    parameters:
      - $ref: '#/components/parameters/Company'
    get:
      tags:
        - Recruitment
      operationId: listDepartments
      summary: List departments
      x-status: available
      x-required-scopes:
        - job_openings.read
      responses:
        '200':
          description: Departments.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /leads/{company}/capture:
    parameters:
      - $ref: '#/components/parameters/Company'
    post:
      tags:
        - Leads
      operationId: captureLead
      summary: Capture a lead
      x-status: available
      x-required-scopes:
        - capture_leads
      description: >-
        Captures a lead. Send `application/json`, or `multipart/form-data` if
        you need to include an `attachment` file. Re-submitting an existing lead
        is de-duplicated server-side and still returns `200`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LeadCapture'
          multipart/form-data:
            schema:
              allOf:
                - $ref: '#/components/schemas/LeadCapture'
                - type: object
                  properties:
                    attachment:
                      type: string
                      format: binary
                      description: >-
                        Allowed: pdf, doc, docx, xls, xlsx, ppt, pptx. Max 10
                        MB.
      responses:
        '200':
          description: Lead captured (or matched to an existing one).
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '500':
          description: The request could not be processed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthError'
  /leads/{company}/sources:
    parameters:
      - $ref: '#/components/parameters/Company'
    get:
      tags:
        - Leads
      operationId: listLeadSources
      summary: List allowed lead sources
      x-status: available
      x-required-scopes:
        - view_lead_sources
      responses:
        '200':
          description: Lead sources.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /storefront/{company}/products:
    parameters:
      - $ref: '#/components/parameters/Company'
    get:
      tags:
        - Storefront
      operationId: storefrontProducts
      summary: List catalog
      x-status: available
      x-required-scopes:
        - storefront
      responses:
        '200':
          description: Catalog.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /storefront/{company}/products/{productId}:
    parameters:
      - $ref: '#/components/parameters/Company'
      - name: productId
        in: path
        required: true
        schema:
          type: string
    get:
      tags:
        - Storefront
      operationId: storefrontProductDetail
      summary: Get a catalog product
      x-status: available
      x-required-scopes:
        - storefront
      responses:
        '200':
          description: Product.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /storefront/{company}/checkout:
    parameters:
      - $ref: '#/components/parameters/Company'
    post:
      tags:
        - Storefront
      operationId: storefrontCheckout
      summary: Create an order / checkout
      description: >-
        Creates a **sales order** (`document_type=sales_order`, status `draft`)
        and starts a payment intent — it does **not** create an invoice, post a
        journal entry, or reserve or decrement stock. The order becomes a real
        invoice only after the payment gateway webhook confirms payment. A
        tenant customer is created (or matched by email, then by the last 9
        digits of the phone) as a side effect.


        **Prices are server-authoritative when `product_id` is supplied:** your
        `unit_price` and `name` are discarded and replaced with the catalogue
        values. They are only honoured for ad-hoc lines that omit `product_id`.


        ⚠️ This endpoint does **not** support idempotency — the
        `Idempotency-Key` header is ignored here, so a retry creates a duplicate
        order.
      x-status: available
      x-required-scopes:
        - storefront
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - items
                - customer
                - currency
                - source_website
                - return_url
                - cancel_url
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items:
                    type: object
                    required:
                      - name
                      - quantity
                      - unit_price
                    properties:
                      name:
                        type: string
                        maxLength: 255
                      quantity:
                        type: number
                        minimum: 0.001
                        maximum: 99999
                      unit_price:
                        type: number
                        minimum: 0.01
                        maximum: 99999999
                      product_id:
                        type:
                          - integer
                          - 'null'
                        description: >-
                          Optional. When set, `name`/`unit_price` are overridden
                          from the catalogue.
                      description:
                        type: string
                      hsn_sac_code:
                        type: string
                customer:
                  type: object
                  required:
                    - name
                    - email
                  properties:
                    name:
                      type: string
                      maxLength: 255
                    email:
                      type: string
                      format: email
                    phone:
                      type: string
                    address:
                      type: string
                    city:
                      type: string
                    state:
                      type: string
                    pincode:
                      type: string
                    country:
                      type: string
                      maxLength: 5
                currency:
                  type: string
                  minLength: 3
                  maxLength: 3
                  enum:
                    - INR
                    - USD
                    - EUR
                    - GBP
                    - AUD
                    - CAD
                    - SGD
                    - MYR
                source_website:
                  type: string
                  maxLength: 255
                return_url:
                  type: string
                  format: uri
                  maxLength: 500
                cancel_url:
                  type: string
                  format: uri
                  maxLength: 500
                tax_amount:
                  type: number
                shipping_amount:
                  type: number
                notes:
                  type: string
                metadata:
                  type: object
      responses:
        '200':
          description: Sales order created and a payment intent opened.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  order_id:
                    type: integer
                  order_number:
                    type: string
                  total_amount:
                    type: number
                  currency:
                    type: string
                  gateway:
                    type: string
                    example: razorpay
                  payment_data:
                    type: object
                    description: >-
                      Gateway-shaped payload (e.g. `razorpay_order_id`,
                      `session_id`, `paypal_order_id`).
        '400':
          description: The payment gateway could not be initialised.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
  /storefront/{company}/order/{orderId}:
    parameters:
      - $ref: '#/components/parameters/Company'
      - name: orderId
        in: path
        required: true
        schema:
          type: string
    get:
      tags:
        - Storefront
      operationId: storefrontOrderStatus
      summary: Order status
      description: >-
        Returns an order **created through this storefront checkout**. Documents
        raised inside the app are not visible here and return `404`, even though
        they live in the same company — this endpoint is deliberately scoped to
        storefront-origin orders only.


        `invoice_url` stays `null` until the order converts to an invoice (i.e.
        until the payment webhook confirms payment).
      x-status: available
      x-required-scopes:
        - storefront
      responses:
        '200':
          description: Order status.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /schools/{company}/locations:
    parameters:
      - $ref: '#/components/parameters/Company'
    get:
      tags:
        - Bookings
      operationId: listSchoolLocations
      summary: List service locations
      description: Active service locations (test centres / pickup points / offices).
      x-status: available
      security:
        - ClientId: []
          ClientSecret: []
        - PublishableKey: []
      responses:
        '200':
          description: Locations.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  locations:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        type:
                          type: string
                          example: test_center
                        city:
                          type: string
                        state:
                          type: string
                        postcode:
                          type: string
                        latitude:
                          type:
                            - number
                            - 'null'
                        longitude:
                          type:
                            - number
                            - 'null'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /schools/{company}/instructors:
    parameters:
      - $ref: '#/components/parameters/Company'
      - name: lat
        in: query
        schema:
          type: number
        description: Latitude (with lng) — distance match vs each instructor's radius_km.
      - name: lng
        in: query
        schema:
          type: number
        description: Longitude (with lat).
      - name: postcode
        in: query
        schema:
          type: string
        description: Instructors servicing this postcode.
      - name: suburb
        in: query
        schema:
          type: string
        description: Instructors servicing this suburb.
      - name: location_id
        in: query
        schema:
          type: integer
        description: Instructors mapped to this service location.
      - name: transmission
        in: query
        schema:
          type: string
          enum:
            - auto
            - manual
            - both
        description: Instructor's transmission (a custom field — `both` is a real value).
      - name: gender
        in: query
        schema:
          type: string
        description: Case-insensitive exact match.
      - name: language
        in: query
        schema:
          type: string
        description: Instructor speaks this language.
      - name: nationality
        in: query
        schema:
          type: string
        description: Case-insensitive exact match.
      - name: car
        in: query
        schema:
          type: string
        description: Filter by the instructor's car.
      - name: available_date
        in: query
        schema:
          type: string
          format: date
        description: Only instructors with free capacity on this date.
      - name: time_of_day
        in: query
        schema:
          type: string
          enum:
            - morning
            - afternoon
            - evening
        description: Narrows `available_date` to a part of the day.
      - name: with_next
        in: query
        schema:
          type: boolean
        description: >-
          Include each instructor's `next_available` slot in the response (costs
          an extra lookup).
      - name: cf
        in: query
        schema:
          type: object
        description: >-
          Match arbitrary instructor custom fields, e.g.
          `cf[transmission]=auto`. See GET /schools/{company}/instructor-fields
          for the available keys.
    get:
      tags:
        - Bookings
      operationId: listInstructors
      summary: Find instructors
      description: >-
        Bookable instructors, optionally filtered to those who service a
        suburb/postcode/point.
      x-status: available
      security:
        - ClientId: []
          ClientSecret: []
        - PublishableKey: []
      responses:
        '200':
          description: Instructors.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  instructors:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        gender:
                          type:
                            - string
                            - 'null'
                        languages:
                          type: array
                          items:
                            type: string
                        transmission:
                          type:
                            - string
                            - 'null'
                        bio:
                          type:
                            - string
                            - 'null'
                        photo_url:
                          type:
                            - string
                            - 'null'
                        service_areas:
                          type: array
                          items:
                            type: object
                            properties:
                              suburb:
                                type: string
                              postcode:
                                type: string
                              state:
                                type: string
                              radius_km:
                                type: number
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /schools/{company}/courses:
    parameters:
      - $ref: '#/components/parameters/Company'
      - name: transmission
        in: query
        schema:
          type: string
          enum:
            - auto
            - manual
      - name: type
        in: query
        schema:
          type: string
          enum:
            - lesson
            - package
      - name: instructor_id
        in: query
        schema:
          type: integer
        description: Only the lessons/packages this instructor actually offers.
    get:
      tags:
        - Bookings
      operationId: listCourses
      summary: List lessons & packages
      x-status: available
      security:
        - ClientId: []
          ClientSecret: []
        - PublishableKey: []
      responses:
        '200':
          description: Courses.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  courses:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        price:
                          type: number
                        currency:
                          type: string
                          example: AUD
                        duration_minutes:
                          type:
                            - integer
                            - 'null'
                        transmission:
                          type:
                            - string
                            - 'null'
                        is_package:
                          type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /schools/{company}/availability:
    parameters:
      - $ref: '#/components/parameters/Company'
      - name: instructor_id
        in: query
        required: true
        schema:
          type: integer
      - name: date
        in: query
        required: true
        schema:
          type: string
          format: date
      - name: duration_minutes
        in: query
        schema:
          type: integer
          minimum: 15
          maximum: 480
        description: Slot length to fit. Outside 15–480 returns 422.
    get:
      tags:
        - Bookings
      operationId: getAvailability
      summary: Get available time slots
      description: >-
        Bookable slots for an instructor on a date, honouring the weekly
        schedule, overrides, existing bookings and booking settings.
      x-status: available
      security:
        - ClientId: []
          ClientSecret: []
        - PublishableKey: []
      responses:
        '200':
          description: Slots.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  available:
                    type: boolean
                  reason:
                    type:
                      - string
                      - 'null'
                  slots:
                    type: array
                    items:
                      type: object
                      properties:
                        start:
                          type: string
                          example: '09:00'
                        end:
                          type: string
                          example: '11:00'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /schools/{company}/instructor-fields:
    parameters:
      - $ref: '#/components/parameters/Company'
    get:
      tags:
        - Bookings
      operationId: listInstructorFields
      summary: List instructor filter fields
      description: >-
        The custom-field definitions available for filtering instructors — use
        the returned `key` values with the `cf[...]` query parameter on `GET
        /instructors` to build facets without hard-coding them.
      x-status: available
      security:
        - ClientId: []
          ClientSecret: []
        - PublishableKey: []
      responses:
        '200':
          description: Field definitions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  fields:
                    type: array
                    items:
                      type: object
                      properties:
                        key:
                          type: string
                          example: transmission
                        label:
                          type: string
                        type:
                          type: string
                        options:
                          type:
                            - array
                            - 'null'
                          items:
                            type: object
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /schools/{company}/customers/lookup:
    parameters:
      - $ref: '#/components/parameters/Company'
      - name: email
        in: query
        schema:
          type: string
          format: email
        description: Exact match.
      - name: phone
        in: query
        schema:
          type: string
        description: Digits are extracted, so formatting is ignored.
    get:
      tags:
        - Bookings
      operationId: lookupSchoolCustomer
      summary: Look up a returning customer
      description: >-
        Returns the stored **name** for a matching email or phone so the widget
        can greet a returning student and pre-fill the form. Pass `email` or
        `phone`.


        ⚠️ This confirms whether a given email/phone is a customer of the school
        and reveals their name. Its only gate is your key's allowed-origins
        list, so treat the key as origin-locked and do not ship it anywhere you
        would not accept that lookup being run.
      x-status: available
      security:
        - ClientId: []
          ClientSecret: []
        - PublishableKey: []
      responses:
        '200':
          description: Match result. `customer` is null when nothing matches.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  customer:
                    type:
                      - object
                      - 'null'
                    properties:
                      name:
                        type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /schools/{company}/nearest-suburb:
    parameters:
      - $ref: '#/components/parameters/Company'
      - name: lat
        in: query
        required: true
        schema:
          type: number
          minimum: -90
          maximum: 90
      - name: lng
        in: query
        required: true
        schema:
          type: number
          minimum: -180
          maximum: 180
    get:
      tags:
        - Bookings
      operationId: nearestSuburb
      summary: Resolve a point to the nearest serviced suburb
      description: >-
        Maps browser geolocation to a suburb you can feed back into `GET
        /instructors?suburb=`.
      x-status: available
      security:
        - ClientId: []
          ClientSecret: []
        - PublishableKey: []
      responses:
        '200':
          description: Nearest suburb.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: '`lat`/`lng` missing or not valid numbers.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /schools/{company}/test-travel-quote:
    parameters:
      - $ref: '#/components/parameters/Company'
      - name: location_id
        in: query
        required: true
        schema:
          type: integer
        description: The test centre. Must exist, else 422.
      - name: lat
        in: query
        required: true
        schema:
          type: number
          minimum: -90
          maximum: 90
        description: Pickup latitude.
      - name: lng
        in: query
        required: true
        schema:
          type: number
          minimum: -180
          maximum: 180
        description: Pickup longitude.
    get:
      tags:
        - Bookings
      operationId: testTravelQuote
      summary: Quote the travel charge for a driving test
      description: >-
        Prices the travel component for a test-day pickup **before** booking, so
        the widget can show it up front. The same charge is recomputed
        server-side when the booking is created.
      x-status: available
      security:
        - ClientId: []
          ClientSecret: []
        - PublishableKey: []
      responses:
        '200':
          description: Quote (currency is AUD). Includes the distance and charge.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  currency:
                    type: string
                    example: AUD
                  travel_distance_km:
                    type:
                      - number
                      - 'null'
                  travel_charge:
                    type:
                      - number
                      - 'null'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: >-
            Invalid params, or the test centre has no location set so distance
            cannot be calculated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /schools/{company}/packages:
    parameters:
      - $ref: '#/components/parameters/Company'
    post:
      tags:
        - Bookings
      operationId: reservePackage
      summary: Reserve a lesson package
      description: >-
        Buys a lesson package for a customer, optionally scheduling its lessons
        up front and optionally taking a **deposit** (`pay_amount`) rather than
        the full price. Supply `return_url`+`cancel_url` to start online payment
        and receive `payment_data`.
      x-status: available
      security:
        - ClientId: []
          ClientSecret: []
        - PublishableKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - customer
                - product_id
              properties:
                customer:
                  $ref: '#/components/schemas/SchoolCustomer'
                product_id:
                  type: integer
                  description: Must be a lesson package, else 422.
                lessons:
                  type: array
                  maxItems: 50
                  description: >-
                    Optional lessons to schedule now. Each must be at least 15
                    minutes.
                  items:
                    type: object
                pay_amount:
                  type:
                    - number
                    - 'null'
                  minimum: 0
                  description: Deposit. Omit to charge the full package price.
                return_url:
                  type:
                    - string
                    - 'null'
                  format: uri
                cancel_url:
                  type:
                    - string
                    - 'null'
                  format: uri
      responses:
        '200':
          description: Package reserved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookingResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /schools/{company}/orders:
    parameters:
      - $ref: '#/components/parameters/Company'
    post:
      tags:
        - Bookings
      operationId: reserveOrder
      summary: Reserve an order of services
      description: >-
        Orders one or more services from an instructor's menu (up to 20 items),
        optionally with a deposit. Supply `return_url`+`cancel_url` to start
        online payment.
      x-status: available
      security:
        - ClientId: []
          ClientSecret: []
        - PublishableKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - customer
                - instructor_id
                - items
              properties:
                customer:
                  $ref: '#/components/schemas/SchoolCustomer'
                instructor_id:
                  type: integer
                items:
                  type: array
                  minItems: 1
                  maxItems: 20
                  description: >-
                    Services from this instructor's menu. An unavailable service
                    returns 422.
                  items:
                    type: object
                pay_amount:
                  type:
                    - number
                    - 'null'
                  minimum: 0
                  description: Deposit. Omit to charge the full total.
                payment_method:
                  type:
                    - string
                    - 'null'
                  enum:
                    - card
                    - bank_transfer
                return_url:
                  type:
                    - string
                    - 'null'
                  format: uri
                cancel_url:
                  type:
                    - string
                    - 'null'
                  format: uri
      responses:
        '200':
          description: Order reserved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookingResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /schools/{company}/bookings:
    parameters:
      - $ref: '#/components/parameters/Company'
    post:
      tags:
        - Bookings
      operationId: createBooking
      summary: Create a booking
      description: >-
        Match/create the customer and create the booking. If
        return_url+cancel_url are supplied and payment is due, payment is
        initiated and payment_url returned. Prices come from your catalogue
        server-side.
      x-status: available
      security:
        - ClientId: []
          ClientSecret: []
        - PublishableKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: >-
                Two booking modes share this endpoint. A **lesson** booking
                sends `start_time`. A **driving-test** booking sends `test_time`
                instead — the block is computed from the exact test time,
                optionally preceded by a warm-up lesson
                (`lesson_before_minutes`) and with test-day pickup/drop-off.
                Exactly one of `start_time` / `test_time` is required.
              required:
                - customer
                - instructor_id
                - booking_date
              properties:
                customer:
                  $ref: '#/components/schemas/SchoolCustomer'
                instructor_id:
                  type: integer
                  description: Must exist — an unknown id is 422, not 404.
                product_id:
                  type:
                    - integer
                    - 'null'
                service_location_id:
                  type:
                    - integer
                    - 'null'
                booking_package_id:
                  type:
                    - integer
                    - 'null'
                  description: >-
                    Book against a pre-paid package (no payment). 403 if the
                    package belongs to another customer.
                booking_date:
                  type: string
                  format: date
                  description: Today or later — a past date is rejected.
                start_time:
                  type:
                    - string
                    - 'null'
                  pattern: ^\d{2}:\d{2}$
                  example: '10:00'
                  description: 24-hour HH:MM. Required unless `test_time` is given.
                end_time:
                  type:
                    - string
                    - 'null'
                  pattern: ^\d{2}:\d{2}$
                  example: '12:00'
                  description: 24-hour HH:MM. Must be after `start_time`.
                duration_minutes:
                  type:
                    - integer
                    - 'null'
                  minimum: 15
                  maximum: 480
                test_time:
                  type:
                    - string
                    - 'null'
                  pattern: ^\d{2}:\d{2}$
                  example: '14:30'
                  description: >-
                    24-hour HH:MM. Sending this makes it a DRIVING-TEST booking;
                    the block is derived from this time.
                lesson_before_minutes:
                  type:
                    - integer
                    - 'null'
                  enum:
                    - 0
                    - 45
                    - 60
                    - 90
                    - 120
                  description: Warm-up lesson immediately before the test.
                pickup_required:
                  type:
                    - boolean
                    - 'null'
                  description: >-
                    Test-day pickup. The pickup coordinates drive the travel
                    charge.
                pickup_lat:
                  type:
                    - number
                    - 'null'
                  minimum: -90
                  maximum: 90
                pickup_lng:
                  type:
                    - number
                    - 'null'
                  minimum: -180
                  maximum: 180
                pickup_address:
                  type:
                    - string
                    - 'null'
                  maxLength: 500
                dropoff_address:
                  type:
                    - string
                    - 'null'
                  maxLength: 500
                student_notes:
                  type:
                    - string
                    - 'null'
                  maxLength: 1000
                return_url:
                  type:
                    - string
                    - 'null'
                  format: uri
                  description: >-
                    Supply BOTH urls to initiate online payment immediately —
                    the response then carries `payment_data`/`payment_url`.
                cancel_url:
                  type:
                    - string
                    - 'null'
                  format: uri
      responses:
        '201':
          description: Booking created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookingResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >-
            Origin not allowed, or the given `booking_package_id` belongs to a
            different customer.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthError'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            The slot is not free. Body is `{ "success": false, "message": "..."
            }`. On the driving-test path it may also carry `manual_review: true`
            and a `proposed_block: { start, end, test_time }` for the operator
            to confirm.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthError'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /schools/{company}/bookings/{id}/pay:
    parameters:
      - $ref: '#/components/parameters/Company'
      - $ref: '#/components/parameters/ResourceId'
    post:
      tags:
        - Bookings
      operationId: payBooking
      summary: Start/retry online payment
      description: >-
        Initiate payment for a **pending, unpaid booking that this widget
        created**. Returns `payment_url`/`payment_data` to redirect to; the
        booking is confirmed by the gateway webhook on success. The amount is
        derived server-side — there is no `amount` or payment-mode field.
        Requires a configured gateway for the currency, else 422.


        Only widget-created, still-pending bookings are payable: handing this
        any other booking id in the tenant returns **403**, not a checkout.
      x-status: available
      security:
        - ClientId: []
          ClientSecret: []
        - PublishableKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - return_url
                - cancel_url
              properties:
                return_url:
                  type: string
                  format: uri
                cancel_url:
                  type: string
                  format: uri
      responses:
        '200':
          description: >-
            Payment started — or the booking was **already paid**, in which case
            the body is `{ success: true, message, payment: { required: false,
            status } }` with **no `booking`** key. Check `payment.required`
            before redirecting.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookingResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >-
            This booking cannot be paid online — it was not created by the
            widget, or is no longer pending. Body is `{ "success": false,
            "message": "..." }`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthError'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            A **partial payment** has already been received; re-initiating would
            charge the full total again, so the balance must be settled with the
            school. Body carries `payment: { required: false, status: "partial"
            }`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthError'
        '422':
          description: >-
            Online payment is not enabled for this currency (body also carries
            `online_payment_enabled: false`), **or** `return_url`/`cancel_url`
            are missing or not valid URLs.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /schools/{company}/bookings/{id}:
    parameters:
      - $ref: '#/components/parameters/Company'
      - $ref: '#/components/parameters/ResourceId'
    get:
      tags:
        - Bookings
      operationId: getBooking
      summary: Get booking status
      x-status: available
      security:
        - ClientId: []
          ClientSecret: []
        - PublishableKey: []
      responses:
        '200':
          description: Booking.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  booking:
                    $ref: '#/components/schemas/Booking'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
components:
  securitySchemes:
    ClientId:
      type: apiKey
      in: header
      name: X-Client-Id
      description: >-
        Public key identifier (lk_id_...). Sent on every request alongside
        X-Client-Secret.
    ClientSecret:
      type: apiKey
      in: header
      name: X-Client-Secret
      description: >-
        Secret key (lk_secret_...). Shown once at creation, stored hashed. Treat
        like a password — never expose in client code.
    PublishableKey:
      type: apiKey
      in: header
      name: X-Client-Id
      description: >-
        Publishable key (pk_...) for in-browser use on the Bookings API only. No
        secret is sent. Origin-locked to allow-listed websites, so it is safe to
        expose in page source.
  parameters:
    Company:
      name: company
      in: path
      required: true
      description: >-
        Your company (workspace) **slug** — the same value as your
        `{slug}.laabam.app` subdomain, shown in **Settings → Company Profile**.
        It is *not* your display name, brand or product name: a company shown as
        “Acme Traders” has the slug `acme-traders`.


        An unknown slug returns **`401`** with the same body as a bad key —
        `Invalid API credentials, or the company was not found.` The two cases
        are intentionally indistinguishable so nobody can probe which company
        slugs exist without valid credentials. If you get that 401, check
        **both** the slug and the key.
      schema:
        type: string
      example: acme-traders
    ResourceId:
      name: id
      in: path
      required: true
      description: Opaque resource identifier.
      schema:
        type: string
    Limit:
      name: limit
      in: query
      description: Page size (1–100).
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
    Cursor:
      name: cursor
      in: query
      description: Opaque cursor from the previous response's paging.next_cursor.
      schema:
        type: string
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: A unique UUID so retries don't double‑create/charge/send. Cached 24h.
      schema:
        type: string
        format: uuid
  headers:
    RateLimitLimit:
      description: The key's requests‑per‑minute limit.
      schema:
        type: integer
    RateLimitRemaining:
      description: Requests remaining in the current window.
      schema:
        type: integer
  responses:
    Unauthorized:
      description: >-
        Missing, invalid, or expired credentials — **or an unknown `{company}`
        slug**, which returns this same 401 with an identical body so the API
        cannot be used to probe which companies exist. Raised by the auth layer,
        so the body is the `AuthError` shape — `{ "success": false, "message":
        "..." }`.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/AuthError'
              - $ref: '#/components/schemas/Error'
    Forbidden:
      description: >-
        The key lacks the required scope (endpoint → `Error`), or the origin is
        not allowed (auth layer → `AuthError`).
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/AuthError'
              - $ref: '#/components/schemas/Error'
    NotFound:
      description: >-
        The addressed resource does not exist (or is not in this company) — e.g.
        an unknown invoice/product/order id. Note an unknown `{company}`
        **slug** does NOT return 404; it returns the same 401 as a bad key (see
        `Unauthorized`).
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/AuthError'
              - $ref: '#/components/schemas/Error'
    Conflict:
      description: Idempotency or state conflict.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ValidationFailed:
      description: The request body failed validation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
        X-RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/AuthError'
              - $ref: '#/components/schemas/Error'
    MessageAccepted:
      description: >-
        **Accepted, not delivered.** The message is queued and handed to the
        provider asynchronously (queued → submitted → delivered). A `202` does
        not prove delivery — for SMS, a DLT template mismatch is dropped by the
        carrier *after* this response. Poll `GET
        /messaging/{company}/messages/{id}` for the real status.
      content:
        application/json:
          schema:
            type: object
            properties:
              data:
                $ref: '#/components/schemas/Message'
  schemas:
    LeadCapture:
      type: object
      description: >-
        Lead payload. Note `first_name`, `last_name` AND `email` are all
        required — there is no single `name` field.
      required:
        - first_name
        - last_name
        - email
      properties:
        first_name:
          type: string
        last_name:
          type: string
        email:
          type: string
          format: email
        phone:
          type: string
        company_name:
          type: string
        lead_type:
          type: string
          enum:
            - contact
            - rfp
            - enquiry
            - demo_request
        service:
          type: string
        source:
          type: string
        source_url:
          type: string
          format: uri
        form_code:
          type: string
        message:
          type: string
          maxLength: 10000
        job_title:
          type: string
        company_size:
          type: string
        industry:
          type: string
        country:
          type: string
        services_needed:
          type: string
        budget_range:
          type: string
        timeline:
          type: string
        project_overview:
          type: string
        goals_expectations:
          type: string
        current_systems:
          type: string
        how_heard:
          type: string
    AuthError:
      type: object
      description: >-
        Shape returned by the **authentication layer** — missing/invalid/expired
        credentials, an unknown `{company}` slug, a blocked origin, or the
        per‑key rate limit. It differs from `Error` (returned by the endpoints
        themselves), so a robust client should handle both shapes.
      properties:
        success:
          type: boolean
          example: false
        message:
          type: string
          example: Invalid API credentials, or the company was not found.
    Error:
      type: object
      description: >-
        Shape returned by the **endpoints** (validation, missing scope, endpoint
        rate limit). Auth‑layer failures use `AuthError` instead.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              example: validation_failed
            message:
              type: string
            details:
              type: array
              items:
                type: object
                properties:
                  field:
                    type: string
                  issue:
                    type: string
            request_id:
              type: string
              example: req_8f3a1c9e
    Paging:
      type: object
      properties:
        next_cursor:
          type:
            - string
            - 'null'
        has_more:
          type: boolean
    SchoolCustomer:
      type: object
      description: >-
        Buyer details for the schools flows. `phone` is required and `email` is
        optional — note this is the opposite of most of the API.
      required:
        - name
        - phone
      properties:
        name:
          type: string
          maxLength: 255
        phone:
          type: string
          maxLength: 30
        email:
          type:
            - string
            - 'null'
          format: email
          maxLength: 255
        address:
          type: string
          maxLength: 500
        city:
          type: string
          maxLength: 120
        state:
          type: string
          maxLength: 120
        postcode:
          type: string
          maxLength: 20
    Booking:
      type: object
      properties:
        id:
          type: integer
        booking_number:
          type: string
          example: BK-000012
        status:
          type: string
          example: confirmed
        payment_status:
          type: string
          example: paid
        booking_type:
          type: string
          description: Which mode created it — a lesson or a driving test.
        test_time:
          type:
            - string
            - 'null'
          example: '14:30'
          description: Set on driving-test bookings.
        lesson_before_minutes:
          type:
            - integer
            - 'null'
          description: Warm-up lesson length before the test.
        pickup_required:
          type:
            - boolean
            - 'null'
        travel_distance_km:
          type:
            - number
            - 'null'
          description: Computed from the pickup point on test bookings.
        travel_charge:
          type:
            - number
            - 'null'
          description: Travel component of the total — derived, not client-set.
        booking_date:
          type: string
          format: date
        start_time:
          type: string
          example: '10:00'
        end_time:
          type: string
          example: '12:00'
        duration_minutes:
          type: integer
        total_amount:
          type: number
        currency:
          type: string
          example: AUD
        service_location_id:
          type:
            - integer
            - 'null'
        product_id:
          type:
            - integer
            - 'null'
        instructor:
          type: object
          description: >-
            On the `POST /bookings` 201 the relation is not eager-loaded, so
            only `id` is present — `name` is absent there. It is populated on
            GET.
          properties:
            id:
              type: integer
            name:
              type:
                - string
                - 'null'
    BookingResult:
      type: object
      description: >-
        `booking` is absent on the "already paid" and "partial payment" branches
        of `POST /bookings/{id}/pay` — always check `payment.required` first.
      properties:
        success:
          type: boolean
        message:
          type:
            - string
            - 'null'
          description: Set on the already-paid / partial-payment branches.
        booking:
          $ref: '#/components/schemas/Booking'
        payment:
          type: object
          description: '`required: false` means there is nothing to pay — do not redirect.'
          properties:
            required:
              type: boolean
            amount:
              type: number
              description: Server-derived; the client cannot set it.
            currency:
              type: string
              example: AUD
            status:
              type:
                - string
                - 'null'
              example: partial
            gateway:
              type: string
              example: stripe
            payment_url:
              type:
                - string
                - 'null'
            payment_data:
              type:
                - object
                - 'null'
              description: >-
                Gateway-shaped payload to hand to the gateway's client SDK (e.g.
                `razorpay_order_id`, `session_id`, `paypal_order_id`). Use this
                or `payment_url` — whichever your gateway needs.
            invoice_id:
              type:
                - integer
                - 'null'
            travel_charge:
              type:
                - number
                - 'null'
              description: Travel component included in `amount` on test bookings.
            online_payment_enabled:
              type: boolean
              description: Present and false on the 422 when no gateway is configured.
    InvoiceItem:
      type: object
      properties:
        product_id:
          type: string
        quantity:
          type: number
        unit_price:
          type: string
          example: '1500.00'
        tax_rate:
          type: number
          example: 18
        amount:
          type: string
          example: '3540.00'
    InvoiceItemCreate:
      type: object
      description: >-
        A line on a new invoice. `item_name`, `quantity`, `unit`, `unit_price`
        and `tax_rate` are all required.
      required:
        - item_name
        - quantity
        - unit
        - unit_price
        - tax_rate
      properties:
        item_name:
          type: string
          maxLength: 255
        quantity:
          type: number
          minimum: 0.01
        unit:
          type: string
          maxLength: 50
          example: pcs
        unit_price:
          type: number
          minimum: 0
          example: 1500
        tax_rate:
          type: number
          minimum: 0
          maximum: 100
          example: 18
        product_id:
          type: integer
          nullable: true
          description: Optional link to a catalogue product.
        description:
          type: string
        hsn_code:
          type: string
          maxLength: 20
        discount_percent:
          type: number
          minimum: 0
          maximum: 100
    InvoiceCreate:
      type: object
      description: >-
        `currency` is not accepted — invoices are INR only in v1. Totals are
        computed server-side. An unknown `customer_id` returns 422 (not 404).
      required:
        - customer_id
        - invoice_date
        - items
      properties:
        customer_id:
          type: string
        invoice_date:
          type: string
          format: date
        due_date:
          type: string
          format: date
        items:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/InvoiceItemCreate'
        document_type:
          type: string
          enum:
            - invoice
            - quotation
            - estimate
            - proforma_invoice
            - sales_order
          default: invoice
        invoice_number:
          type: string
          maxLength: 50
          description: Auto-generated when omitted.
        reference_number:
          type: string
          maxLength: 255
        gst_invoice_type:
          type: string
          enum:
            - regular
            - bill_of_supply
            - export_sez
        reverse_charge:
          type: string
          enum:
            - 'Y'
            - 'N'
        terms:
          type: string
        notes:
          type: string
    Invoice:
      type: object
      properties:
        id:
          type: string
          example: inv_5c1a
        number:
          type: string
          example: INV/2025-26/041
        status:
          type: string
          enum:
            - unpaid
            - partial
            - paid
            - voided
        customer_id:
          type: string
        currency:
          type: string
          example: INR
        subtotal:
          type: string
          example: '3000.00'
        tax_amount:
          type: string
          example: '540.00'
        round_off:
          type: string
          example: '0.00'
        total:
          type: string
          example: '3540.00'
        due_amount:
          type: string
          example: '3540.00'
        invoice_date:
          type: string
          format: date
        due_date:
          type: string
          format: date
        items:
          type: array
          items:
            $ref: '#/components/schemas/InvoiceItem'
        created_at:
          type: string
          format: date-time
    Address:
      type: object
      properties:
        line1:
          type: string
        city:
          type: string
        state:
          type: string
        postal_code:
          type: string
        country:
          type: string
          example: IN
    CustomerCreate:
      type: object
      description: >-
        Address fields are FLAT (there is no nested `billing_address` object)
        and the GST field is `gst_number` (not `gstin`). Uniqueness: `name`
        (case-insensitive) and `gst_number` must be unique — a clash returns
        422. Email and phone are NOT uniqueness-checked. Setting `also_vendor`
        additionally opens a Sundry Creditors account and assigns a vendor
        number.
      required:
        - name
        - state
        - pincode
      properties:
        name:
          type: string
          maxLength: 255
        email:
          type: string
          format: email
          maxLength: 255
        phone:
          type: string
          maxLength: 20
        phone_country_code:
          type: string
        alternate_phone:
          type: string
          maxLength: 20
        address:
          type: string
        address_label:
          type: string
        area:
          type: string
        city:
          type: string
        state:
          type: string
        pincode:
          type: string
        country:
          type: string
          maxLength: 2
          description: ISO alpha-2.
        gst_number:
          type: string
          maxLength: 15
        gst_type:
          type: string
        legal_name:
          type: string
        pan_number:
          type: string
          maxLength: 10
        customer_type:
          type: string
          enum:
            - individual
            - business
        currency_code:
          type: string
          minLength: 3
          maxLength: 3
        discount:
          type: number
          minimum: 0
          maximum: 100
        credit_limit:
          type: number
          minimum: 0
        website_url:
          type: string
          format: uri
        also_vendor:
          type: boolean
        is_active:
          type: boolean
        notes:
          type: string
    CustomerUpdate:
      description: >-
        Partial update — every field is optional. Same field names and rules as
        `CustomerCreate`.
      allOf:
        - $ref: '#/components/schemas/CustomerCreate'
        - type: object
          required: []
    Customer:
      type: object
      description: Shape returned by the READ endpoints (list + get by id).
      properties:
        id:
          type: string
          example: '42'
          description: Stringified numeric id.
        customer_number:
          type: string
        name:
          type: string
        email:
          type: string
          format: email
        phone:
          type: string
        gstin:
          type: string
          description: The stored `gst_number`, echoed under this key on reads.
        pan:
          type: string
        address:
          $ref: '#/components/schemas/Address'
        currency:
          type: string
        is_active:
          type: boolean
        created_at:
          type: string
          format: date-time
    CustomerWriteResult:
      type: object
      description: >-
        Shape returned by POST/PATCH — deliberately different from `Customer`:
        the GST value comes back as `gst_number` (not `gstin`), currency as
        `currency_code`, and there is no nested `address` or `pan`.
      properties:
        id:
          type: string
          example: '42'
        customer_number:
          type: string
        name:
          type: string
        email:
          type: string
          format: email
        phone:
          type: string
        gst_number:
          type: string
        state:
          type: string
        pincode:
          type: string
        currency_code:
          type: string
        is_active:
          type: boolean
        also_vendor:
          type: boolean
        account_id:
          type:
            - integer
            - 'null'
          description: Sundry Debtors ledger account.
        vendor_account_id:
          type:
            - integer
            - 'null'
          description: Sundry Creditors account when `also_vendor`.
        created_at:
          type: string
          format: date-time
    ProductCreate:
      type: object
      description: >-
        There is no `unit_price` or `tax_rate` field — prices are
        `purchase_price` / `selling_price` / `mrp_price` and the tax rate is
        `gst_rate`. `unit_id` is required alongside `name`. `sku` and `barcode`
        must be unique (422 on clash).
      required:
        - name
        - unit_id
      properties:
        name:
          type: string
        unit_id:
          type: integer
          description: Required. Must exist in the company's units.
        type:
          type: string
          enum:
            - product
            - service
          default: product
        invoice_name:
          type: string
          description: Defaults to `name`.
        sku:
          type: string
          maxLength: 100
          description: Must be unique.
        barcode:
          type: string
          maxLength: 100
          description: Must be unique.
        category_id:
          type: integer
        brand_id:
          type: integer
        description:
          type: string
        purchase_price:
          type: number
          minimum: 0
        selling_price:
          type: number
          minimum: 0
        mrp_price:
          type: number
          minimum: 0
        selling_price_tax_mode:
          type: string
          enum:
            - exclusive
            - inclusive
        gst_rate:
          type: number
          minimum: 0
          maximum: 100
          example: 18
        hsn_code:
          type: string
          maxLength: 20
        sac_code:
          type: string
          maxLength: 20
        status:
          type: string
          enum:
            - active
            - inactive
          default: active
        track_stock:
          type: boolean
          description: Create only — not updatable via PATCH.
        opening_stock:
          type: number
        min_stock:
          type: number
        max_stock:
          type: number
        low_stock_alert:
          type: boolean
        is_batch_tracked:
          type: boolean
        is_expiry_tracked:
          type: boolean
    ProductUpdate:
      type: object
      description: >-
        Partial update. Accepts a strict SUBSET of `ProductCreate` — `type`,
        `track_stock` and the `opening_*` / batch fields are NOT updatable and
        are silently ignored if sent.
      properties:
        name:
          type: string
        unit_id:
          type: integer
        invoice_name:
          type: string
        sku:
          type: string
          maxLength: 100
        barcode:
          type: string
          maxLength: 100
        category_id:
          type: integer
        brand_id:
          type: integer
        description:
          type: string
        purchase_price:
          type: number
          minimum: 0
        selling_price:
          type: number
          minimum: 0
        mrp_price:
          type: number
          minimum: 0
        selling_price_tax_mode:
          type: string
          enum:
            - exclusive
            - inclusive
        gst_rate:
          type: number
          minimum: 0
          maximum: 100
        hsn_code:
          type: string
          maxLength: 20
        sac_code:
          type: string
          maxLength: 20
        status:
          type: string
          enum:
            - active
            - inactive
        min_stock:
          type: number
        max_stock:
          type: number
        low_stock_alert:
          type: boolean
    Product:
      type: object
      description: >-
        Shape returned by the READ endpoints. ⚠️ Reads and writes currently use
        different keys for the same values — a read returns `sale_price` / `mrp`
        / `track_inventory` where a write accepts/returns `selling_price` /
        `mrp_price` / `track_stock`. Do not assume you can read back exactly
        what you wrote.
      properties:
        id:
          type: string
          example: '42'
          description: Stringified numeric id.
        name:
          type: string
        sku:
          type: string
        hsn_code:
          type: string
        sale_price:
          type:
            - number
            - 'null'
        mrp:
          type:
            - number
            - 'null'
        gst_rate:
          type:
            - number
            - 'null'
        track_inventory:
          type: boolean
        current_stock:
          type: number
        created_at:
          type: string
          format: date-time
    ProductWriteResult:
      type: object
      description: Shape returned by POST/PATCH (see the key-naming warning on `Product`).
      properties:
        id:
          type: string
          example: '42'
        name:
          type: string
        invoice_name:
          type: string
        sku:
          type: string
        unit_id:
          type: integer
        purchase_price:
          type:
            - number
            - 'null'
        selling_price:
          type:
            - number
            - 'null'
        mrp_price:
          type:
            - number
            - 'null'
        gst_rate:
          type:
            - number
            - 'null'
        track_stock:
          type: boolean
        status:
          type: string
        current_stock:
          type: number
        created_at:
          type: string
          format: date-time
    PaymentCreate:
      type: object
      required:
        - invoice_id
        - amount
      properties:
        invoice_id:
          type: string
        amount:
          type: string
          example: '3540.00'
        method:
          type: string
          enum:
            - cash
            - bank_transfer
            - upi
            - card
            - cheque
        paid_at:
          type: string
          format: date
        reference:
          type: string
    Payment:
      type: object
      description: >-
        Shape returned by the READ endpoints. Note there is no `method` /
        `paid_at` field anywhere in the API — payments carry a `payment_mode_id`
        and a `payment_date`.
      properties:
        id:
          type: string
          example: '42'
          description: Stringified numeric id.
        payment_number:
          type: string
        invoice_id:
          type: string
        customer_id:
          type: string
        amount:
          type: number
          example: 3540
        currency:
          type: string
        reference:
          type:
            - string
            - 'null'
        status:
          type: string
        payment_date:
          type: string
          format: date
        created_at:
          type: string
          format: date-time
    PaymentWriteResult:
      type: object
      description: >-
        Shape returned by POST — differs from `Payment`: no
        `customer_id`/`currency`, and it adds `journal_entry_id` plus the
        recalculated `invoice` block. `journal_entry_id` may be **null**: the
        receipt JE is posted best-effort and the payment still succeeds if it
        could not be written.
      properties:
        id:
          type: string
          example: '42'
        payment_number:
          type: string
        invoice_id:
          type: string
        amount:
          type: number
        payment_date:
          type: string
          format: date
        status:
          type: string
        journal_entry_id:
          type:
            - integer
            - 'null'
        created_at:
          type: string
          format: date-time
        invoice:
          type: object
          properties:
            id:
              type: string
            paid_amount:
              type: number
            due_amount:
              type: number
            status:
              type: string
            payment_status:
              type: string
    Message:
      type: object
      properties:
        id:
          type: string
          example: msg_7b3c
        channel:
          type: string
          enum:
            - sms
            - whatsapp
            - email
        status:
          type: string
          description: >-
            SMS: queued -> submitted -> delivered | failed. WhatsApp: sent ->
            delivered -> read | failed.
          enum:
            - queued
            - submitted
            - sent
            - delivered
            - read
            - failed
            - rejected
        to:
          type: string
        sent_at:
          type:
            - string
            - 'null'
          format: date-time
        delivered_at:
          type:
            - string
            - 'null'
          format: date-time
          description: Set from the carrier DLR (SMS) or Meta status callback (WhatsApp).
        read_at:
          type:
            - string
            - 'null'
          format: date-time
          description: WhatsApp only.
        failed_at:
          type:
            - string
            - 'null'
          format: date-time
        error:
          type:
            - string
            - 'null'
          description: Failure reason, when status is failed.
        created_at:
          type: string
          format: date-time
    Job:
      type: object
      properties:
        id:
          type: string
        slug:
          type: string
        title:
          type: string
        department:
          type: string
        location:
          type: string
        employment_type:
          type: string
        description:
          type: string
