openapi: 3.1.0
info:
  title: LetterAgent API
  version: 1.0.0
  description: >
    Agent-native print-and-mail for real, physical letters (US + Canada).
    No signup, no API key — the MVP has no auth. Flow: POST /quotes with the
    letter content for the exact verified price (the page count is rendered,
    not estimated), get explicit user approval of that exact price, POST /jobs
    with the same content and the quote_id to create the job and receive a
    one-time Stripe Checkout URL, user pays, the Stripe webhook fulfils the job
    by creating the letter. GET /jobs/{job_id} reports payment, fulfilment and
    delivery status separately; POST /jobs/{job_id}/cancel cancels while the
    fulfilment window is open. Shipping labels are COMING SOON and not available.
  contact:
    name: LetterAgent
    email: hello@getletteragent.com
servers:
  - url: https://api.getletteragent.com/v1
    description: Production API.
tags:
  - name: letters
    description: Quote, create, and track physical letter jobs.
paths:
  /health:
    get:
      tags: [letters]
      summary: Health check
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  payment_mode:
                    type: string
                    enum: [mock, real]
                    description: >
                      mock = test checkout, no real charge, test letters are
                      never mailed (live: false). real = live Stripe Checkout.

  /quotes:
    post:
      tags: [letters]
      summary: Get an exact quote for a letter
      description: >
        Pass the letter content — exactly one of html, text, or pdf_url — and
        the backend renders it in the standard print layout, verifies the REAL
        page count, and returns the exact final price. Never an estimate: the
        quote binds the price to that exact content via a content hash. The
        quote's country must match the destination address countryCode. Quotes
        are short-lived; create the job promptly.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [country]
              properties:
                country:
                  type: string
                  enum: [US, CA]
                color:
                  type: boolean
                  default: false
                  description: True for colour printing, false for black-and-white.
                registered:
                  type: boolean
                  default: false
                  description: >
                    True for registered mail (US: certified with proof of mailing
                    and delivery tracking; CA: registered with tracking and
                    signature on delivery).
                province_or_state:
                  type: string
                  description: >
                    Recipient province/territory, e.g. "ON". For Canadian
                    letters the quote previews the applicable sales tax, added
                    on top of the letter price at checkout.
                html:
                  type: string
                  description: >
                    Letter body HTML. LetterAgent sets the standard print layout
                    (US Letter 8.5x11in, 1in margins, 12pt serif); your own page
                    CSS is ignored. Exactly one of html / text / pdf_url.
                text:
                  type: string
                  description: >
                    Plain-text letter body (blank lines start new paragraphs).
                    Exactly one of html / text / pdf_url.
                pdf_url:
                  type: string
                  format: uri
                  description: >
                    HTTPS URL of a PDF of the letter, hosted on a public
                    address. Exactly one of html / text / pdf_url.
      responses:
        '201':
          description: Quote created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Quote'
        '400':
          $ref: '#/components/responses/BadRequest'

  /jobs:
    post:
      tags: [letters]
      summary: Create a letter job and get its Stripe Checkout URL
      description: >
        Call ONLY after the user explicitly approves the exact quoted price.
        Pass the SAME content the quote was created from: the quote binds the
        price to that exact content, and the backend re-verifies it before
        creating anything. If the content or page count differs, the call
        fails with 400 and code `quote_content_mismatch` — NO job and NO
        payment session are created. Request a fresh quote and re-confirm the
        new price with the user instead. A price the user did not approve is
        never charged.
        `to`/`from` require addressLine1, city, countryCode ("US"|"CA") and a
        name: `firstName` (+ optional `lastName`), `companyName`, or the
        convenience `name` field ("Jane Smith" is split into first/last
        automatically). A contact with no name is rejected with 400 before
        any payment is created.
        Exactly one of `html` / `text` / `pdf_url` is required. Retrying with
        the same `idempotency_key` returns the original job (HTTP 200) instead
        of creating a duplicate — never a second charge.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [quote_id, letter]
              properties:
                quote_id:
                  type: string
                  description: quote_id from POST /quotes. Binds the price to the exact quoted content.
                idempotency_key:
                  type: string
                  description: >
                    Fresh UUID per user-confirmed action. Reuse it when
                    retrying the same action.
                letter:
                  type: object
                  required: [to, from]
                  properties:
                    to: { $ref: '#/components/schemas/Address' }
                    from: { $ref: '#/components/schemas/Address' }
                    html:
                      type: string
                      description: >
                        Letter body HTML — the SAME content the quote was created
                        from. LetterAgent sets the standard print
                        layout (US Letter 8.5x11in, 1in margins, 12pt serif);
                        keep the top ~3 inches of the first page free for the
                        recipient address block. Your own page CSS is ignored.
                    text:
                      type: string
                      description: >
                        Plain-text letter body — the SAME content the quote was
                        created from.
                    pdf_url:
                      type: string
                      format: uri
                      description: >
                        HTTPS URL of the letter PDF — the SAME content the quote
                        was created from.
      responses:
        '201':
          description: Job created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobCreated'
        '200':
          description: >
            Duplicate idempotency_key — the original job is returned,
            not a new one.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobCreated'
        '400':
          $ref: '#/components/responses/BadRequest'

  /jobs/{job_id}:
    get:
      tags: [letters]
      summary: Job status — payment, fulfilment, delivery
      description: >
        payment_status is pending, paid or failed. fulfillment_status is
        not_started, queued, in_production, mailed, failed, cancelled or
        unknown. delivery_status is untracked for standard mail — never claim
        delivery without carrier evidence.
      parameters:
        - name: job_id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Job view
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobView'
        '404':
          $ref: '#/components/responses/NotFound'

  /jobs/{job_id}/cancel:
    post:
      tags: [letters]
      summary: Cancel a letter job
      description: >
        Cancel while the fulfilment window is still open. Before payment:
        nothing was charged and the checkout link is expired. After payment
        but before fulfilment: the payment is refunded. Once the letter is
        being printed, cancellation fails with 409 and reason
        `already_in_production`. Idempotent: cancelling an already-cancelled
        job returns cancelled=true.
      parameters:
        - name: job_id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id: { type: string }
                  cancelled: { type: boolean }
                  refund:
                    type: string
                    enum: [not_applicable, issued, contact_support]
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Cannot cancel
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id: { type: string }
                  cancelled: { type: boolean, example: false }
                  reason:
                    type: string
                    enum: [already_in_production, refund_failed, job_failed]

  /letters/{letter_id}:
    get:
      tags: [letters]
      summary: Raw letter record
      parameters:
        - name: letter_id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Letter record
          content:
            application/json:
              schema:
                type: object
                description: Raw letter object.

  /webhooks/stripe:
    post:
      tags: [letters]
      summary: Stripe webhook (server-to-server)
      description: >
        Receives Stripe events. On `checkout.session.completed` the job is
        fulfilled: the letter is created with a per-job
        Idempotency-Key. Safe to receive twice — fulfilled jobs are never
        re-created. In production, the event is verified with the Stripe
        webhook secret.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Raw Stripe event payload.
      responses:
        '200':
          description: Event handled
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  job_id: { type: string }
                  letter_id: { type: string, nullable: true }
                  ignored: { type: string, nullable: true }

components:
  schemas:
    Address:
      type: object
      required: [addressLine1, city, countryCode]
      description: >
        A name is also required: firstName (+ optional lastName), companyName,
        or the convenience field `name` (e.g. "Jane Smith", split into
        first/last automatically). Contacts without a name are rejected
        with 400 before payment.
      properties:
        addressLine1: { type: string }
        addressLine2: { type: string }
        city: { type: string }
        companyName: { type: string }
        countryCode:
          type: string
          enum: [US, CA]
        firstName: { type: string }
        lastName: { type: string }
        name:
          type: string
          description: >
            Convenience field, e.g. "Jane Smith" — split into
            firstName/lastName automatically.
        postalOrZip: { type: string }
        provinceOrState: { type: string }
    Quote:
      type: object
      properties:
        quote_id: { type: string }
        currency: { type: string, example: "USD" }
        country: { type: string, enum: [US, CA] }
        color: { type: boolean }
        pages:
          type: integer
          description: Server-verified page count, rendered in the standard print layout.
        registered: { type: boolean }
        amount_cents: { type: integer }
        amount: { type: string, example: "C$6.15" }
        tax_cents:
          type: integer
          description: >
            Canadian sales tax in cents, added on top of amount_cents at
            checkout. Present only for Canadian letters when the recipient
            province is known.
        tax_label:
          type: string
          example: "HST (13%)"
        total_cents:
          type: integer
          description: amount_cents + tax_cents; what the customer actually pays.
        total:
          type: string
          example: "C$6.95"
        content_hash:
          type: string
          description: SHA-256 fingerprint binding the price to the exact quoted content.
    JobCreated:
      type: object
      properties:
        job_id: { type: string, example: "job_a1b2c3d4e5f60718" }
        status: { type: string, example: "awaiting_payment" }
        payment_status:
          type: string
          enum: [pending, paid, failed]
        amount_cents: { type: integer }
        amount: { type: string, example: "C$6.15" }
        tax_cents:
          type: integer
          description: >
            Canadian sales tax in cents, computed from the recipient's
            province and itemized as its own line on the Stripe Checkout page.
            Present only for Canadian letters.
        tax_label:
          type: string
          example: "HST (13%)"
        total_cents:
          type: integer
          description: amount_cents + tax_cents; what the customer actually pays.
        total:
          type: string
          example: "C$6.95"
        currency: { type: string, example: "CAD" }
        pages: { type: integer, description: Verified page count. }
        checkout_url:
          type: string
          description: One-time Stripe Checkout URL to hand to the user.
        payment_mode:
          type: string
          enum: [mock, real]
    JobView:
      type: object
      properties:
        job_id: { type: string }
        status:
          type: string
          enum: [awaiting_payment, paid, fulfilled, failed, cancelled]
        payment_status:
          type: string
          enum: [pending, paid, failed]
        fulfillment_status:
          type: string
          enum: [not_started, queued, in_production, mailed, failed, cancelled, unknown]
          description: >
            in_production covers the provider's ready/printing states; mailed
            covers processed_for_delivery/completed. Standard mail is untracked
            past this point.
        delivery_status:
          type: string
          enum: [untracked, awaiting_tracking, in_transit, delivered]
          description: >
            untracked for standard mail — delivery is never claimed without
            carrier evidence. Registered mail: awaiting_tracking once mailed
            (tracking appears a few days after mailing), in_transit when
            carrier tracking is available, delivered only on explicit carrier
            delivery evidence.
        tracking_number:
          type: string
          description: >
            Carrier tracking number for registered mail, once the carrier has
            issued it (live mode only; test mode issues no tracking).
        tracking_url:
          type: string
          description: Carrier tracking link, when provided.
        tracking_carrier:
          type: string
          description: Carrier name (e.g. USPS, Canada Post), when known.
        amount_cents: { type: integer }
        amount: { type: string }
        tax_cents:
          type: integer
          description: >
            Canadian sales tax in cents, added on top at checkout. Present
            only for Canadian letters.
        tax_label: { type: string }
        total_cents:
          type: integer
          description: amount_cents + tax_cents; what the customer actually paid.
        total: { type: string }
        currency: { type: string }
        letter_id: { type: string, nullable: true }
        error: { type: string, nullable: true }
        created_at: { type: number }
  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
    NotFound:
      description: Not found
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
