{
  "openapi": "3.0.3",
  "info": {
    "title": "PitchStation Invoice API",
    "version": "2026-07",
    "description": "Create, deliver, and chase invoices programmatically. Money is INTEGER CENTS everywhere. Auth: `Authorization: Bearer pst_...` (Personal Access Token, mint at /tokens.html, scope to create.invoice). Human quickstart: https://www.pitchstation.ai/developers",
    "contact": {
      "email": "hello@pitchstation.ai"
    }
  },
  "servers": [
    {
      "url": "https://www.pitchstation.ai"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/api/invoices": {
      "post": {
        "summary": "Create an invoice (optionally send it in the same call)",
        "description": "With `external_ref` the call is idempotent per account: an identical replay returns the existing invoice (200, `idempotent_replay: true`); a replay with a different amount or recipient returns 409 REF_PAYLOAD_MISMATCH. With `send: true` the invoice is validated, created, numbered, and emailed atomically — any failure creates nothing. With `test: true` it numbers in a separate TEST- series, sends no email, and self-purges after 30 days.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InvoiceCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created (and sent, when send:true)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SendResult"
                }
              }
            }
          },
          "200": {
            "description": "Idempotent replay — the existing invoice for this external_ref"
          },
          "409": {
            "description": "REF_PAYLOAD_MISMATCH | NO_ITEMS | ZERO_TOTAL"
          },
          "429": {
            "description": "SEND_CAP — daily send cap for the plan tier"
          }
        }
      },
      "get": {
        "summary": "List invoices (+aging) or look up by external_ref",
        "parameters": [
          {
            "name": "external_ref",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "draft",
                "sent",
                "paid",
                "void",
                "overdue",
                "partially_paid"
              ]
            }
          },
          {
            "name": "since",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "client_email",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "issuer_id",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "maximum": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "invoices[] (+aging when unfiltered; test invoices excluded from aging)"
          }
        }
      }
    },
    "/api/invoices/{id}": {
      "get": {
        "summary": "Invoice detail + event timeline + live URL",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      },
      "patch": {
        "summary": "Edit (drafts freely; sent = revision on the same link; paid/void locked)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "400": {
            "description": "TEST_IMMUTABLE | REF_IMMUTABLE"
          },
          "409": {
            "description": "LOCKED_PAID | LOCKED_PARTIAL"
          }
        }
      },
      "delete": {
        "summary": "Delete a draft (sent invoices are voided, never deleted)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "409": {
            "description": "Not a draft"
          }
        }
      }
    },
    "/api/invoices/{id}/send": {
      "post": {
        "summary": "Number, mint the tracked link, email the client",
        "description": "Client email matching a PitchStation account → account-locked link (no password). Otherwise link + password in the same email, rotated on resend. `{\"attach\": true}` attaches the archival PDF. Resend reuses the same URL.",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "201": {
            "description": "Sent",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SendResult"
                }
              }
            }
          },
          "200": {
            "description": "Resent (same link)"
          },
          "429": {
            "description": "SEND_CAP"
          }
        }
      }
    },
    "/api/invoices/{id}/payments": {
      "post": {
        "summary": "Record a payment (blank amount = full balance)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "amount_cents": {
                    "type": "integer"
                  },
                  "method": {
                    "type": "string"
                  },
                  "reference": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Recorded; full payment → status paid + receipt email"
          }
        }
      }
    },
    "/api/invoices/{id}/pdf": {
      "get": {
        "summary": "Archival application/pdf (DRAFT stamp on unsent)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "PDF bytes"
          },
          "503": {
            "description": "PDF_RENDER_OFF | PDF_BUSY"
          }
        }
      }
    },
    "/api/invoices/{id}/snapshots": {
      "get": {
        "summary": "Compliance snapshots frozen at each send (sha256-indexed)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "snapshots[]"
          }
        }
      }
    },
    "/api/invoices/{id}/void": {
      "post": {
        "summary": "Void (number retained)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/invoices/{id}/credit-note": {
      "post": {
        "summary": "Credit note against a paid invoice",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "201": {
            "description": "Created"
          },
          "409": {
            "description": "NOT_PAID"
          }
        },
        "description": "Pass {\"refund\": true} to also refund the Stripe-settled payment (recorded as a negative gateway row; event invoice.refunded). The credit note stands even if the refund leg fails."
      }
    },
    "/api/invoices/{id}/remind": {
      "post": {
        "summary": "Manual reminder (scheduler covers due-3/due/+7/+14)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/invoices/{id}/recurrence": {
      "post": {
        "summary": "Monthly recurrence from this invoice",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "day_of_month": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 28
                  },
                  "auto_send": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created"
          }
        }
      }
    },
    "/api/invoices/settings/profile": {
      "patch": {
        "summary": "Issuer identity on every artifact (frozen per-invoice at send)",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "legal_name": {
                    "type": "string"
                  },
                  "tax_id": {
                    "type": "string"
                  },
                  "address": {
                    "type": "string"
                  },
                  "billing_email": {
                    "type": "string"
                  },
                  "footer_text": {
                    "type": "string"
                  },
                  "logo_base64": {
                    "type": "string",
                    "description": "PNG/JPEG/WebP only — transcoded server-side; SVG rejected. null clears."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          },
          "400": {
            "description": "LOGO_FORMAT | LOGO_DECODE"
          },
          "413": {
            "description": "LOGO_TOO_LARGE"
          }
        }
      }
    },
    "/api/webhooks": {
      "get": {
        "summary": "List webhook endpoints (secrets never repeated)",
        "responses": {
          "200": {
            "description": "endpoints[], events[], max"
          }
        }
      },
      "post": {
        "summary": "Register a webhook endpoint (max 3; https only)",
        "description": "The signing secret is returned ONCE. Verify deliveries: X-PitchStation-Signature: t=<unix>,v1=HMAC-SHA256(secret, t + '.' + rawBody); reject when |now - t| > 300s. Retries 1m/10m/1h/6h/24h then dead-letter. Endpoints resolving to private/link-local/metadata addresses are refused.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri"
                  },
                  "events": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "invoice.sent",
                        "invoice.viewed",
                        "invoice.payment_claimed",
                        "invoice.paid",
                        "invoice.overdue",
                        "invoice.voided",
                        "invoice.credit_noted",
                        "invoice.refunded"
                      ]
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "endpoint + secret (once)"
          },
          "400": {
            "description": "BAD_URL | BAD_EVENTS"
          },
          "409": {
            "description": "WEBHOOK_LIMIT"
          }
        }
      }
    },
    "/api/webhooks/{id}": {
      "delete": {
        "summary": "Remove an endpoint",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/webhooks/{id}/deliveries": {
      "get": {
        "summary": "Delivery log (status, attempts, resolved IP)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "deliveries[]"
          }
        }
      }
    },
    "/api/webhooks/{id}/test": {
      "post": {
        "summary": "Send a signed ping to verify the receiver",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "delivery result"
          }
        }
      }
    },
    "/api/invoices/settings/rails": {
      "post": {
        "summary": "Opt this account into online collection (Pay now)",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "rail": {
                    "type": "string",
                    "enum": [
                      "stripe"
                    ]
                  },
                  "enabled": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          },
          "503": {
            "description": "COLLECT_OFF — collection disabled server-side"
          }
        }
      }
    },
    "/api/invoices/issuers": {
      "get": {
        "summary": "List billing identities (default issuer always present)",
        "responses": {
          "200": {
            "description": "issuers[]"
          }
        }
      },
      "post": {
        "summary": "Create an issuer (own legal block, logo, gap-free register)",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "label": {
                    "type": "string"
                  },
                  "legal_name": {
                    "type": "string"
                  },
                  "tax_id": {
                    "type": "string"
                  },
                  "address": {
                    "type": "string"
                  },
                  "billing_email": {
                    "type": "string"
                  },
                  "footer_text": {
                    "type": "string"
                  },
                  "number_prefix": {
                    "type": "string"
                  },
                  "logo_base64": {
                    "type": "string",
                    "description": "PNG/JPEG/WebP only; transcoded server-side"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "issuer"
          }
        }
      }
    },
    "/api/invoices/issuers/{id}": {
      "patch": {
        "summary": "Update an issuer (audit-logged; issued documents never change)",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "issuer"
          }
        }
      },
      "delete": {
        "summary": "Delete an unused, non-default issuer",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "409": {
            "description": "IN_USE | IS_DEFAULT"
          }
        }
      }
    },
    "/api/invoices/reconciliation": {
      "get": {
        "summary": "Month-end finance rollup",
        "parameters": [
          {
            "name": "month",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "issued / paid_gateway / paid_manual / refunded by currency+issuer, plus current outstanding/overdue"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Personal Access Token (pst_...)"
      }
    },
    "parameters": {
      "id": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "integer"
        }
      }
    },
    "schemas": {
      "InvoiceItem": {
        "type": "object",
        "required": [
          "description"
        ],
        "properties": {
          "description": {
            "type": "string"
          },
          "qty": {
            "type": "number",
            "default": 1
          },
          "unit": {
            "type": "string"
          },
          "unit_cents": {
            "type": "integer",
            "description": "INTEGER CENTS — 250000 = $2,500.00"
          }
        }
      },
      "InvoiceCreate": {
        "type": "object",
        "properties": {
          "external_ref": {
            "type": "string",
            "maxLength": 128,
            "pattern": "^[A-Za-z0-9._:/-]+$",
            "description": "Your order id — idempotency key, unique per account, immutable"
          },
          "send": {
            "type": "boolean",
            "description": "Create + number + email atomically; failure creates nothing"
          },
          "test": {
            "type": "boolean",
            "description": "TEST- series, no emails, auto-purged after 30 days, immutable"
          },
          "attach": {
            "type": "boolean",
            "description": "With send:true — attach the archival PDF to the client email"
          },
          "client_name": {
            "type": "string"
          },
          "client_email": {
            "type": "string"
          },
          "client_company": {
            "type": "string"
          },
          "client_address": {
            "type": "string"
          },
          "currency": {
            "type": "string",
            "pattern": "^[A-Z]{3}$",
            "default": "HKD"
          },
          "issue_date": {
            "type": "string",
            "format": "date"
          },
          "due_date": {
            "type": "string",
            "format": "date"
          },
          "terms": {
            "type": "string"
          },
          "notes": {
            "type": "string"
          },
          "tax_label": {
            "type": "string"
          },
          "tax_cents": {
            "type": "integer"
          },
          "discount_cents": {
            "type": "integer"
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/InvoiceItem"
            }
          },
          "paid": {
            "type": "object",
            "description": "Receipt mode (requires send:true): money already moved on your PSP — records the caller-attested payment, stamps PAID, sends the receipt email instead of the invoice email.",
            "properties": {
              "amount_cents": {
                "type": "integer",
                "description": "Defaults to the grand total"
              },
              "method": {
                "type": "string"
              },
              "reference": {
                "type": "string"
              }
            }
          },
          "metadata": {
            "type": "object",
            "maxProperties": 20,
            "description": "Correlation data (≤2KB serialized, scalar values) echoed in every webhook and GET; never rendered on documents."
          },
          "issuer_id": {
            "type": "integer",
            "description": "Bill under a specific issuer identity (see /api/invoices/issuers); omitted = your default issuer."
          }
        }
      },
      "SendResult": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "number": {
            "type": "string",
            "example": "INV-2026-0031"
          },
          "url": {
            "type": "string",
            "description": "The tracked link the client received"
          },
          "password": {
            "type": "string",
            "description": "Present only for clients without a PitchStation account — already emailed to them"
          },
          "test": {
            "type": "boolean"
          },
          "would_email": {
            "type": "string",
            "description": "Test mode: the address that WOULD have received the email"
          },
          "idempotent_replay": {
            "type": "boolean"
          },
          "invoice": {
            "type": "object"
          }
        }
      }
    }
  }
}