openapi: 3.1.0

# The machine-readable contract for DueTrail's public API.
#
# It is served at https://duetrail.com/openapi.yaml because the connector
# marketplaces consume it directly: Pipedream generates most of a component
# from a spec, n8n's declarative nodes are written against one, and Make's
# Custom Apps import it. Keeping it in the repo next to the handlers is what
# stops it drifting from what the API actually does.
#
# Compatibility rule: fields may be added, never renamed or removed. A live
# scenario in somebody's account breaks the moment either happens.

info:
  title: DueTrail API
  version: "1.0.0"
  description: |
    Read collection events, invoices and cases, and write invoices, payments
    and notes.

    **Authentication.** Every request carries an API key as a bearer token:

    ```
    Authorization: Bearer dt_live_...
    ```

    Keys are created in DueTrail under Settings → Integrations → API keys, are
    shown once at creation, and are scoped to one workspace. Revoke a key there
    if it leaks; there is no recovery path for a lost key, so issue a new one.

    **Triggers.** `GET /events` is the polling source. Pass the `next_since`
    from the previous response as `since` on the next poll. Events carry a
    stable `id`, so a connector platform can dedupe if a page boundary repeats
    one.

    **Money is a string,** never a JSON number, so decimal precision survives
    the round trip. `"1400.05"` parsed as a float and written back to an
    accounting system is a rounding bug.
  contact:
    name: DueTrail support
    url: https://duetrail.com/contact
  license:
    name: Proprietary

servers:
  - url: https://duetrail.com/api/public
    description: Production

security:
  - apiKey: []

tags:
  - name: Account
    description: Verify a key and identify its workspace.
  - name: Events
    description: The polling source for triggers.
  - name: Invoices
  - name: Cases

paths:
  /me:
    get:
      tags: [Account]
      summary: Verify the API key
      description: |
        Returns the workspace the key belongs to. Cheap by design — this is the
        connection test every platform runs during setup.
      operationId: getMe
      responses:
        "200":
          description: The key is valid.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /events:
    get:
      tags: [Events]
      summary: List collection events
      description: |
        Returns events created after `since`, oldest first. Time-cursored
        rather than offset-paginated: new events arrive constantly at the head
        of the timeline, so an offset would skip or repeat rows between polls.

        Pass the response's `next_since` on the following call. When a page is
        empty the cursor is returned unchanged, so a quiet period cannot skip
        an event written mid-request.
      operationId: listEvents
      parameters:
        - name: since
          in: query
          description: >-
            Exclusive lower bound, RFC3339. Defaults to 24 hours ago, so a
            connector's first poll returns a useful sample rather than the
            workspace's entire history.
          schema: { type: string, format: date-time }
          example: "2026-08-23T09:00:00Z"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of events.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventsPage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /invoices:
    get:
      tags: [Invoices]
      summary: List invoices
      operationId: listInvoices
      parameters:
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/InvoiceStatus" }
        - name: overdue
          in: query
          description: When true, returns only invoices past their due date and not settled.
          schema: { type: boolean }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of invoices, soonest due first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InvoicesPage" }
        "401": { $ref: "#/components/responses/Unauthorized" }

    post:
      tags: [Invoices]
      summary: Create an invoice
      description: |
        Creates one invoice and opens its collection case, under the same
        review-first rules an import applies — a workspace still in onboarding
        review gets a paused case, not an immediate reminder.

        **Send `external_id`.** It is the idempotency key. Connector platforms
        re-run timed-out steps as a matter of course; without it a retry
        creates a duplicate invoice and the customer is chased twice for the
        same money. A repeat call with a known `external_id` returns `200` with
        `deduplicated: true` and writes nothing.

        The customer is resolved from `customer_id`, then
        `external_customer_id`, then `customer_name`; a name we have not seen
        creates a customer. Supply `customer_email` in that case — a customer
        with no contact produces a case that can never send a reminder.
      operationId: createInvoice
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateInvoice" }
      responses:
        "201":
          description: The invoice was created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateInvoiceResult" }
        "200":
          description: >-
            An earlier call with this `external_id` already created the
            invoice. Nothing was written.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateInvoiceResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /cases:
    get:
      tags: [Cases]
      summary: List collection cases
      operationId: listCases
      parameters:
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/CaseStatus" }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of cases, soonest due first.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CasesPage" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /cases/{id}/payments:
    post:
      tags: [Cases]
      summary: Record a payment
      description: |
        Applies a payment to the case's invoice. A payment that clears the
        outstanding amount settles the invoice and closes the case, and
        resolves any outstanding promise to pay.
      operationId: recordPayment
      parameters:
        - $ref: "#/components/parameters/CaseID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecordPayment" }
      responses:
        "201":
          description: The payment was recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RecordPaymentResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /cases/{id}/notes:
    post:
      tags: [Cases]
      summary: Add a note to a case
      description: >-
        Appends a note to the case timeline, attributed to the API rather than
        to a team member.
      operationId: addNote
      parameters:
        - $ref: "#/components/parameters/CaseID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AddNote" }
      responses:
        "204":
          description: The note was added.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: >-
        A workspace API key, created under Settings → Integrations. Keys begin
        `dt_live_` and are shown once at creation.

  parameters:
    Limit:
      name: limit
      in: query
      description: Page size. Defaults to 50, capped at 200.
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    Offset:
      name: offset
      in: query
      schema: { type: integer, minimum: 0, default: 0 }
    CaseID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }

  responses:
    BadRequest:
      description: The request could not be parsed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: The API key is missing, unknown or revoked.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such record in this workspace.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unprocessable:
      description: The request parsed but broke a business rule.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
          description: A human-readable message. Branch on the HTTP status, not on this.
      required: [error]

    Money:
      type: string
      description: >-
        A decimal amount as a string, so precision survives the round trip.
        Never parse it into a float before writing it to an accounting system.
      example: "1400.05"
      pattern: '^-?\d+(\.\d+)?$'

    Me:
      type: object
      properties:
        org_id: { type: string, format: uuid }
        org_name: { type: string }
        api_version: { type: string, example: "v1" }
        key_name:
          type: string
          description: The name given to the key that answered, so several connectors are distinguishable.
        key_prefix: { type: string, example: "dt_live_a1b2" }
      required: [org_id, org_name, api_version]

    EventType:
      type: string
      description: |
        The event types available as triggers. Internal scheduler bookkeeping
        is deliberately not exposed.
      enum:
        - promise_created
        - promise_broken
        - promise_kept
        - payment_recorded
        - case_closed_paid
        - case_closed
        - reminder_sent
        - manual_reminder_sent
        - portal_customer_reported_paid
        - portal_customer_question

    Event:
      type: object
      properties:
        id: { type: string, format: uuid }
        type: { $ref: "#/components/schemas/EventType" }
        case_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        payload:
          type: object
          additionalProperties: true
          description: >-
            Type-specific detail. Its shape varies by event type and may gain
            fields; read defensively.
      required: [id, type, case_id, created_at]

    EventsPage:
      type: object
      properties:
        events:
          type: array
          items: { $ref: "#/components/schemas/Event" }
        next_since:
          type: string
          format: date-time
          description: >-
            Pass this as `since` on the next poll. Equal to the newest returned
            event, or to the cursor you sent when the page is empty.
        has_more:
          type: boolean
          description: True when more events are already waiting; poll again immediately.
      required: [events, next_since, has_more]

    InvoiceStatus:
      type: string
      enum: [draft, issued, due, overdue, partially_paid, paid, void, written_off]

    Invoice:
      type: object
      properties:
        id: { type: string, format: uuid }
        invoice_number: { type: string }
        customer_id: { type: string, format: uuid }
        amount_total: { $ref: "#/components/schemas/Money" }
        outstanding_amount: { $ref: "#/components/schemas/Money" }
        currency: { type: string, example: "EUR" }
        status: { $ref: "#/components/schemas/InvoiceStatus" }
        issued_at: { type: string, format: date-time }
        due_date: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
      required: [id, invoice_number, customer_id, amount_total, outstanding_amount, currency, status, due_date]

    InvoicesPage:
      type: object
      properties:
        invoices:
          type: array
          items: { $ref: "#/components/schemas/Invoice" }
        has_more: { type: boolean }
      required: [invoices, has_more]

    CreateInvoice:
      type: object
      properties:
        invoice_number: { type: string, example: "INV-2026-004" }
        external_id:
          type: string
          description: >-
            Your own identifier for this invoice, and the idempotency key.
            Strongly recommended — see the endpoint description.
        customer_id:
          type: string
          format: uuid
          description: An existing DueTrail customer. Takes precedence over the other two.
        external_customer_id:
          type: string
          description: Your own identifier for the customer.
        customer_name:
          type: string
          description: Required unless customer_id is given. A name we have not seen creates a customer.
        customer_email:
          type: string
          format: email
          description: >-
            Becomes the primary contact when the customer is created here.
            Without any contact the case can never send a reminder.
        amount_total: { $ref: "#/components/schemas/Money" }
        outstanding_amount:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: Defaults to amount_total. May not exceed it.
        currency: { type: string, example: "EUR", minLength: 3, maxLength: 3 }
        issued_at:
          type: string
          description: "YYYY-MM-DD or RFC3339. Defaults to today."
          example: "2026-07-01"
        due_date:
          type: string
          description: "YYYY-MM-DD or RFC3339."
          example: "2026-07-31"
      required: [invoice_number, amount_total, currency, due_date]

    CreateInvoiceResult:
      type: object
      properties:
        invoice: { $ref: "#/components/schemas/Invoice" }
        case_id:
          type: [string, "null"]
          format: uuid
          description: >-
            Null when no case was opened — the invoice was already settled, or
            one is already active for it.
        deduplicated:
          type: boolean
          description: True when a prior call with this external_id already created the invoice.
      required: [invoice, deduplicated]

    CaseStatus:
      type: string
      enum: [open, paused, promise_pending, closed_paid, closed]

    Case:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { $ref: "#/components/schemas/CaseStatus" }
        priority:
          type: string
          enum: [low, normal, high, urgent]
        customer_id: { type: string, format: uuid }
        customer_name: { type: string }
        invoice_id: { type: string, format: uuid }
        invoice_number: { type: string }
        outstanding_amount: { $ref: "#/components/schemas/Money" }
        currency: { type: string }
        due_date: { type: string, format: date-time }
        days_overdue: { type: integer, minimum: 0 }
        next_reminder_at:
          type: [string, "null"]
          format: date-time
          description: Null when the case is paused, closed, or has no steps left.
        created_at: { type: string, format: date-time }
      required: [id, status, priority, customer_id, invoice_id, outstanding_amount, currency, due_date, days_overdue]

    CasesPage:
      type: object
      properties:
        cases:
          type: array
          items: { $ref: "#/components/schemas/Case" }
        has_more: { type: boolean }
      required: [cases, has_more]

    RecordPayment:
      type: object
      properties:
        amount: { $ref: "#/components/schemas/Money" }
        note:
          type: string
          description: Optional context recorded alongside the payment, e.g. the payment method.
      required: [amount]

    RecordPaymentResult:
      type: object
      properties:
        payment_id: { type: string, format: uuid }
        invoice_status: { $ref: "#/components/schemas/InvoiceStatus" }
        case_status: { $ref: "#/components/schemas/CaseStatus" }
        promise_resolved:
          type: boolean
          description: True when this payment settled an outstanding promise to pay.
      required: [payment_id, invoice_status, case_status, promise_resolved]

    AddNote:
      type: object
      properties:
        content:
          type: string
          maxLength: 5000
      required: [content]
