openapi: 3.0.3
info:
  title: Ultimate Invoice Printer — Public REST API
  version: "1.0.0"
  description: |
    Server-to-server API for generating, sending and retrieving the PDF
    documents Ultimate Invoice Printer produces for a Shopify store.

    **Authentication** — every request carries an API key created under
    Settings → API Keys, in the `X-Api-Key` header (or `Authorization: Bearer
    <key>`). Keys are shop-scoped: a key can only ever address its own shop's
    orders, draft orders, templates and documents. Available on the
    Enterprise Plus plan; a downgrade turns keys off immediately (403).

    **Documents and quota** — generating through the API runs the exact
    pipeline the in-app buttons use: the monthly document quota is enforced
    atomically, a fresh existing archive is reused (not re-billed) in `auto`
    mode, and every materially different render becomes a new immutable
    version.

    **Rate limit** — 120 requests per minute per key (best-effort, per
    server instance); exceeding it returns 429 with a `Retry-After` header.

    **Errors** — every non-2xx response is
    `{ "ok": false, "error": { "code", "message" } }`. Internal detail is
    never exposed; codes are stable.
servers:
  - url: https://{app-host}
    variables:
      app-host:
        default: shopify-invoice-printer.droidinfinity.com
security:
  - ApiKeyAuth: []
tags:
  - name: Invoices
    description: Generate, send and inspect documents for orders and draft orders.
  - name: Templates
  - name: Documents

paths:
  /api/v1/invoices/generate:
    post:
      tags: [Invoices]
      summary: Generate (or reuse) a document and return a download link
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GenerateRequest"
      responses:
        "200":
          description: Document archived; a short-lived download URL is included.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerateResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/Internal" }

  /api/v1/invoices/send:
    post:
      tags: [Invoices]
      summary: Generate (or reuse) a document and email it to the customer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendRequest"
      responses:
        "200":
          description: Email sent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/Internal" }

  /api/v1/invoices/{order_id}:
    get:
      tags: [Invoices]
      summary: The current archived document for an order or draft order
      parameters:
        - $ref: "#/components/parameters/OrderId"
        - $ref: "#/components/parameters/DocumentTypeQuery"
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvoiceDetails"
          description: Current document details plus a short-lived download URL.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/Internal" }

  /api/v1/templates:
    get:
      tags: [Templates]
      summary: List the shop's custom templates and the active template per document type
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  active:
                    type: object
                    description: Document type → active custom template id or built-in theme slug (e.g. "horizon").
                    additionalProperties: { type: string }
                  templates:
                    type: array
                    items: { $ref: "#/components/schemas/TemplateSummary" }
          description: Templates listed.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /api/v1/templates/{id}:
    get:
      tags: [Templates]
      summary: One custom template with its Liquid files, CSS, block overlay and settings
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  template: { $ref: "#/components/schemas/TemplateDetail" }
          description: Template found.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /api/v1/documents/{id}/download:
    get:
      tags: [Documents]
      summary: Download the document's current PDF
      description: |
        Redirects (302) to a signed storage URL valid for 15 minutes. Pass
        `?redirect=false` to receive the URL as JSON instead. `id` is the
        `document_id` returned by the invoice endpoints.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: redirect
          in: query
          required: false
          schema: { type: string, enum: ["false"] }
      responses:
        "302":
          description: Redirect to the signed download URL.
          headers:
            Location: { schema: { type: string, format: uri } }
        "200":
          description: With `redirect=false` — the signed URL as JSON.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  document_id: { type: string }
                  filename: { type: string }
                  download_url: { type: string, format: uri }
                  download_url_expires_in: { type: integer, example: 900 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: A key from Settings → API Keys (`sk_live_…`). `Authorization: Bearer <key>` is accepted too.

  parameters:
    OrderId:
      name: order_id
      in: path
      required: true
      description: |
        Shopify's numeric resource id (the long id in the admin URL — NOT the
        order number `#1042`), or a full GID. For `document_type=invoice` this
        is an Order; for `estimate` it is a DraftOrder. A GID of the wrong
        resource kind is rejected (400), never reinterpreted.
      schema: { type: string, example: "5678901234567" }
    DocumentTypeQuery:
      name: document_type
      in: query
      required: false
      schema: { $ref: "#/components/schemas/DocumentType" }

  schemas:
    DocumentType:
      type: string
      enum: [invoice, estimate]
      default: invoice
      description: Invoice documents come from Orders; Estimates from Draft Orders.
    PdfMode:
      type: string
      enum: [auto, reuse, regenerate]
      default: auto
      description: |
        What to do when the order already has an archived PDF. `auto` reuses
        a fresh archive (not re-billed) and regenerates a stale one; `reuse`
        always serves the archive if present; `regenerate` always renders.
    GenerateRequest:
      type: object
      required: [order_id]
      properties:
        order_id:
          description: Shopify's numeric Order / DraftOrder id or its GID — not the order number.
          oneOf: [{ type: integer }, { type: string }]
          example: 5678901234567
        document_type: { $ref: "#/components/schemas/DocumentType" }
        mode: { $ref: "#/components/schemas/PdfMode" }
    SendRequest:
      allOf:
        - $ref: "#/components/schemas/GenerateRequest"
        - type: object
          properties:
            additional_recipients:
              type: array
              maxItems: 5
              items: { type: string, format: email }
              description: Extra addresses to send alongside the customer's (max 5).
    GenerateResponse:
      type: object
      properties:
        ok: { type: boolean, enum: [true] }
        order_id: { type: string }
        document_type: { $ref: "#/components/schemas/DocumentType" }
        document_id: { type: string, nullable: true }
        archived:
          type: string
          enum: [existing, created, not-persisted]
          description: Whether a fresh existing archive was reused, a new version rendered, or the document was generated but not stored (free plan / storage failure).
        filename: { type: string }
        download_url: { type: string, format: uri, nullable: true }
        download_url_expires_in: { type: integer, nullable: true, example: 900 }
    SendResponse:
      type: object
      properties:
        ok: { type: boolean, enum: [true] }
        order_id: { type: string }
        document_type: { $ref: "#/components/schemas/DocumentType" }
        sent: { type: boolean, enum: [true] }
        sent_to:
          description: Actual recipient(s) — the test address when Test Mode is on.
          oneOf: [{ type: string }, { type: array, items: { type: string } }, { type: "null" }]
        archived:
          type: string
          enum: [existing, created, not-persisted]
          description: "`not-persisted`: generated and delivered, but no archive row exists (free plan, or a storage failure that was rolled back)."
        test_mode: { type: boolean }
    InvoiceDetails:
      type: object
      properties:
        ok: { type: boolean, enum: [true] }
        document_id: { type: string }
        order_id: { type: string }
        order_name: { type: string, example: "#1042" }
        document_type: { $ref: "#/components/schemas/DocumentType" }
        invoice_number: { type: string, nullable: true, example: "INV-2026-00042" }
        invoice_number_status: { type: string, nullable: true, enum: [issued, void, null] }
        generated_at: { type: string, format: date-time }
        template:
          type: object
          properties:
            source: { type: string, enum: [system, custom, order-override, ""] }
            id: { type: string }
            name: { type: string }
        version_count: { type: integer }
        filename: { type: string }
        download_url: { type: string, format: uri, nullable: true }
        download_url_expires_in: { type: integer, nullable: true }
    TemplateSummary:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string }
        document_type: { $ref: "#/components/schemas/DocumentType" }
        theme: { type: string, nullable: true, description: Built-in theme this was forked from. }
        status: { type: string, enum: [draft, published] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    TemplateDetail:
      allOf:
        - $ref: "#/components/schemas/TemplateSummary"
        - type: object
          properties:
            files:
              type: object
              additionalProperties: { type: string }
              description: Path → Liquid source (`shell.liquid`, `head.liquid`, `blocks/*.liquid`).
            css: { type: string }
            blocks: { type: object, description: Per-block settings overlay keyed by block id. }
            settings: { type: object, description: Theme settings (colours, fonts, formats). }
    Error:
      type: object
      properties:
        ok: { type: boolean, enum: [false] }
        error:
          type: object
          properties:
            code:
              type: string
              enum: [unauthorized, forbidden, not_found, bad_request, quota_exceeded, rate_limited, render_failed, send_failed, in_progress, method_not_allowed, internal]
            message: { type: string }

  responses:
    BadRequest:
      description: Malformed body or parameters (`bad_request`).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unauthorized:
      description: Missing, unknown or revoked key (`unauthorized`).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: The shop's plan does not include the API (`forbidden`).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: No such document or template for this shop (`not_found`).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unprocessable:
      description: The document could not be rendered or sent (`render_failed` / `send_failed`).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Conflict:
      description: The same document is already being generated or sent by another request (`in_progress`) — retry after the `Retry-After` seconds.
      headers:
        Retry-After: { schema: { type: integer } }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    TooManyRequests:
      description: Rate limit (`rate_limited`, with `Retry-After`) or monthly quota reached (`quota_exceeded`).
      headers:
        Retry-After: { schema: { type: integer } }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Internal:
      description: Unexpected error — safe to retry (`internal`).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
