PitchStation · Developers

The Invoice API: one secret, one call, one webhook

Create an invoice from your platform and PitchStation does the rest — tracked delivery with the password logic handled, automatic reminders before and after the due date, client "I've paid" claims, receipts, credit notes, archival PDFs, and signed webhooks back to you. Money is integer cents, everywhere, always.

Authentication

Every call carries a Personal Access Token: Authorization: Bearer pst_…. Sign in and mint one at /tokens.html with the Invoicing integration preset — invoices, payments and your account profile, nothing else — ideally on a service account, so a leaked token can touch nothing but invoices. Tokens show their last-used time there; rotate freely.

Quickstart — the whole integration

# One idempotent call: create + number + email + start chasing.
curl -X POST https://pitchstation.ai/api/invoices \
  -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
  -d '{
    "external_ref": "order-2026-00417",
    "send": true,
    "client_name": "Acme Corp", "client_email": "ap@acme.com",
    "currency": "USD", "due_date": "2026-08-15", "terms": "NET 30",
    "items": [{ "description": "Vending machine x3", "qty": 3, "unit_cents": 250000 }]
  }'

# → 201 { "number": "INV-2026-0031", "url": "https://…/s/…?k=…",
#         "password": "amber-falcon-42",   ← only for clients without an account
#         "invoice": { "id": 87, "status": "sent", … } }

# Retried the call? Same external_ref + same payload → 200, idempotent_replay: true.
# Same ref but a DIFFERENT amount/recipient → 409 REF_PAYLOAD_MISMATCH (that's a bug, not a retry).
Dry-run safely: add "test": true — the invoice numbers in a separate TEST- series, no email leaves (the response shows would_email), the artifact is stamped TEST INVOICE, it never touches your aging or our metrics, and it self-purges after 30 days.

Receipt mode — the money already moved

# Your PSP collected; you need the numbered document. One call:
curl -X POST https://pitchstation.ai/api/invoices \
  -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
  -d '{ "external_ref": "order-417", "send": true,
        "paid": { "method": "stripe", "reference": "pi_3PqK…" },
        "client_name": "Acme", "client_email": "ap@acme.com",
        "currency": "USD", "items": [{ "description": "Order 417", "qty": 1, "unit_cents": 87825 }] }'
# → 201 { number, url, invoice: { status: "paid" } } — PAID stamp, RECEIPT email, no reminders.
# Provenance note: caller-attested payments record as declared_by "owner";
# "gateway" is reserved for settlements we verified by signature ourselves.

Getting paid on the invoice — "Pay now"

When the server has collection enabled and you've opted your account in (POST /api/invoices/settings/rails {"rail":"stripe","enabled":true}), every payable invoice page shows a Pay now button: the client pays by card on a Stripe-hosted page and the invoice flips to PAID by signed webhook — no human touch, exactly-once, amount-verified. Your invoice.paid webhook fires as usual. Refunds ride credit notes: POST /api/invoices/:id/credit-note {"refund": true} refunds the Stripe payment and records the correction (event invoice.refunded). FLAG requires server-side collection to be enabled — ask before relying on it.

Multiple billing identities — issuers

One account can invoice under several identities (entities, brands, stores): GET/POST/PATCH/DELETE /api/invoices/issuers — each issuer carries its own legal block, logo, and its own gap-free number register ({"number_prefix":"HK"} → HK-2026-0001). Pass "issuer_id" on create; filter lists with ?issuer_id=; webhook payloads carry it. Your existing single-profile setup is the "default issuer" — nothing changes until you add a second.

Compatibility & testing

The API evolves additively — new fields and endpoints appear; existing shapes, codes and semantics do not change or disappear. No version prefix to pin: write tolerant readers (ignore unknown fields) and you will never break. Use "test": true for anything exploratory, and poll GET /api/invoices/reconciliation?month=YYYY-MM at month-end for your books. AI drafting endpoints (/chat, /draft, /extract-contract) carry a daily per-user allowance — 429 INVOICE_AI_QUOTA; composing JSON directly is never capped.

Correlation & bookkeeping

"metadata": { … } (≤20 keys, ≤2 KB, scalar values) is stored and echoed verbatim in every webhook and GET — never rendered on the document. List filters: GET /api/invoices?status=…&since=YYYY-MM-DD&client_email=…&limit=…&offset=… (returns total). Month-end rollup: GET /api/invoices/reconciliation?month=YYYY-MM — issued / paid-by-gateway / paid-manual / refunded by currency and issuer, plus current outstanding/overdue.

Webhooks — never poll

curl -X POST https://pitchstation.ai/api/webhooks \
  -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
  -d '{ "url": "https://your-platform.com/hooks/pitchstation",
        "events": ["invoice.viewed", "invoice.paid", "invoice.overdue"] }'
# → 201 { "endpoint": { "id": 3, … }, "secret": "whsec_…" }   ← shown ONCE, store it

# Every delivery is signed:
#   X-PitchStation-Signature: t=1789300000,v1=<hex>
#   v1 = HMAC-SHA256(secret, t + "." + rawBody)          — reject if |now−t| > 300s
# Retries: 1m / 10m / 1h / 6h / 24h, then dead-letter + email to you.
# POST /api/webhooks/:id/test sends a signed ping; GET /api/webhooks/:id/deliveries is the log.

Events: invoice.sent · invoice.viewed · invoice.payment_claimed · invoice.paid · invoice.overdue · invoice.voided · invoice.credit_noted. Payloads carry ids, external_ref, status, and totals — never document content.

// Verify in Node:
const crypto = require('crypto');
function verify(sigHeader, rawBody, secret) {
  const { t, v1 } = Object.fromEntries(sigHeader.split(',').map(p => p.split('=')));
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expect = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expect), Buffer.from(v1));
}

The rest of the surface

CallDoes
POST /api/invoices/:id/paymentsRecord money received (blank amount = full balance) — e.g. when your PSP confirms. Full payment → PAID stamp + receipt email.
GET /api/invoices?external_ref=…Look an invoice up by your order id.
GET /api/invoices/:id/pdfArchival application/pdf (drafts carry a DRAFT stamp). FLAG requires the server's PDF renderer to be enabled — expect PDF_RENDER_OFF until it is.
GET /api/invoices/:id/snapshotsThe sha256-indexed compliance PDFs frozen at each send.
POST /api/invoices/:id/credit-note · /void · /remindCorrections, voiding (number kept), manual reminder.
POST /api/invoices/:id/recurrenceMonthly retainers — drafted or auto-sent.
PATCH /api/invoices/settings/profileYour seller identity on every artifact: legal name, tax ID, address, footer, logo (PNG/JPEG/WebP only — transcoded server-side). Frozen onto each invoice at send.

Full OpenAPI 3 spec: /api/openapi-invoices.json — import it into Postman or generate a client.

The Websites API — generate, approve, publish, measure

The same PAT drives programmatic website creation — built for platforms whose users each need a personal site (agents, brokers, franchisees). One call creates the site from your content JSON; the built-in approval loop means nothing goes live without a compliance sign-off on an audited link; one more call publishes it to a real server with its own domain and TLS; aggregate visitor metrics report back.

curl -X POST https://pitchstation.ai/api/website/generate \
  -H "Authorization: Bearer $PITCHSTATION_PAT" -H "Content-Type: application/json" \
  -d '{
    "external_ref": "agent-12345",
    "metadata": { "store": "bridgelink360", "agentLevel": "L2" },
    "content": {
      "meta": { "company_name": "Alice Chen — Bridgelink 360", "lang": "en" },
      "hero": { "lede": "Your neighborhood tech partner.", "chips": ["Phones", "Tablets", "Smart home"] },
      "products": [{ "image": "<data URI>", "name": "Galaxy S26", "price": "US$899", "link": "https://shop.example.com/p/s26?ref=12345" }],
      "testimonials": [{ "quote": "Fast, honest, always available.", "name": "M. Rivera", "role": "Customer since 2024" }],
      "video": { "url": "https://www.youtube.com/watch?v=XXXXXXXXXXX" },
      "contact": { "email": "alice@example.com", "website_url": "https://shop.example.com/?ref=12345" }
    },
    "assets": { "logo": "<base64>" }
  }'

Full OpenAPI 3 spec: /api/openapi-websites.json. A complete integration guide (lifecycle, content model, catalog, onboarding checklist) is available to partners on request — ask hello@pitchstation.ai. Website-specific error codes: BAD_EXTERNAL_REF, BAD_METADATA, BAD_VIDEO_URL (400, video must be YouTube/Vimeo), BAD_SECTION_ORDER (400, unknown/duplicate section ids), BAD_PAGES (400, invalid multi-page map), BAD_PRESET / BAD_SOURCE / BRIEF_TOO_SHORT / BAD_LANG (400), BAD_TOKENS (400, brand tokens outside bounds), BAD_DOMAIN / DOMAIN_TAKEN (400/409, base-domain claims), ASSIST_QUOTA (429, per-day AI pool), SITE_QUOTA (429, plan site cap), PUBLISH_QUOTA (429, daily live publishes), NOT_APPROVED (409, approval gate), SUBDOMAIN_TAKEN (409), LLM_NOT_CONFIGURED (503, brief drafting needs server AI).

The fast paths (July 2026)

No content model? Send a brief. POST /api/website/generate {"brief":"a plain-text company description (40–24,000 chars)","lang":"en","assets":{"logo":…}} — the server AI-drafts the model and renders in one call, echoing the draft + gap warnings. Start from a preset: "preset":"agent"|"electronics"|"jewelry"|"fashion" expands server-side under your values (the agent preset is the partner referral card — mount each agent's unique link in cta_banner.link; identical content across an account raises a DUPLICATE_CONTENT warning). Multi-page: content.meta.pages=[{"slug":"","title":"Home","sections":[…]},…] (≤8 pages, slug "" = index) renders one static file per page with shared nav; review links cover the whole site. Draft without creating: POST /api/website/extract accepts a brochure PDF, up to 6 photos, one .txt/.md file, or JSON {"text":…} — one response contract for all four.

Partner self-service. POST /api/website/base-domain {"domain":"sites.yourbrand.com"} → add the returned TXT + wildcard-A records → /base-domain/verify: every site then publishes under YOUR domain. PUT /api/website/brand-tokens {"accent":"#FF6800","ink":"#001F5B","paper":"#FFFFFF","font_pair":"montserrat"} re-skins your (locked) template within accessibility clamps — a rebrand is one call.

The Apps API — host a running prototype, not just a document

Websites are static; some deliverables are running software. The Apps API deploys any project with a docker-compose.yml (a Vite + React or Next.js app is scaffolded for you) to an app host the platform manages, and returns a live TLS URL under the host's base domain (for example <slug>.apps-ap.pitchstation.ai) or at your own domain — public, or private behind a sign-in page, so forwarding the address alone never opens it (an access link opens it for whoever holds the link, until you revoke it or it expires). The host is a shared host for teammates, an isolated host of your own, or a host you registered: the control plane drives it over SSH, holds the credential encrypted, and keeps the release history. Start with the Apps product page and the user manual.

Status in this release. Two hosts serve apps: Singapore (*.apps-ap.pitchstation.ai, ARM64, so images must build for ARM64) is the shared host for teammates, with Compose rules and per-app budgets enforced; Oregon (*.apps.pitchstation.ai, x86) is the platform's own host, where they only warn. New apps use the managed route (per-host TLS and a policy check on every request) from their first deploy. Six apps from before 18 September keep their original routes until their next domains or access change. Custom-domain TXT ownership proofs are implemented; the first production use is pending. Live changes run only on hosts an admin has approved: discover GET /api/apps/publishing-capabilities and the owned app’s /domain-config rather than assuming.

Content and configuration are separate. Create an app once, inspect a deploy dry-run, then upload reviewed content through /deploy. For an existing app’s addresses or access, use the Go-live workspace, PUT /:id/domain-config or PATCH /:id/publication-policy: plan → verify each hostname → apply → check readiness. These configuration operations do not build, create a release or restart the app. They require If-Match, the reviewed plan ID and Idempotency-Key on apply. Correct A/CNAME routing can stay; add the app-scoped TXT proof at your DNS provider.

Durable work, explicit limits. Managed deploy/rollback with Prefer: respond-async returns 202 and an operation ID; reconnect by polling or retrying identical intent with the same key. A container or shared database can change before routing succeeds, and a release that fails its health check has already replaced the running containers. Rollback brings back a release's Compose file and the images it names, with the last applied env, the current budget and the deploy settings (code built on the host only when each release tags its images differently); it does not undo migrations. Env PATCH preserves unrelated keys and restarts the current release; env PUT remains replace-all/stored-only. Private policy requires a retained password; platform public access cannot remove the application’s own login/expiry. HTTP readiness is not an interactive browser test. A public app passes readiness only after its owner declares applicationPolicy: "none" (the app itself restricts nobody) — new apps start at unknown; see the deploy guide.

Four ways to drive it: the create-pitchstation-app skill; the standalone Apps client pitchstation-apps.cjs (one file, Node 20+, no checkout needed); six MCP tools — pitchstation_app_list, _status, _logs, _deploy, _operation, _readiness; or REST. None of them grants deployment rights. Full documentation: /apps-docs/ — user manual · deploy guide · API reference · architecture · product page.

Certificate staging checks passed for all ten existing hostnames. Production renewal acceptance and separate alert delivery remain pending. APP_ACME_EMAIL is not an alert subscription: Let’s Encrypt ended expiry emails; PitchStation monitoring/email were not enabled with this pilot.

Full OpenAPI 3 spec: /api/openapi-apps.json. Access is gated on the app.deploy capability, which is never granted by default — running your own prototypes and running a stranger's code are different products, so this one arrives only by an explicit admin grant.

Dev Instances — a cloud workbench sized for one repository

A dev instance is a VPS with Claude Code, Kimi CLI or Codex pre-installed, sized from the repository it will hold. PitchStation is the registry and policy engine only: it never makes a cloud call and holds no login credential for any box. Your machine provisions (AWS, under a scoped IAM profile) and confirms each step; the box pushes its own telemetry with a per-box report token and stops itself when idle. The protocol is POST /api/devboxes (reserve — capability, name and your monthly cost cap are checked before anything exists in the cloud) → the provisioner launches from GET /:id/bootstrap (secret-free, pinned cloud-init) → POST /:id/provisioning → POST /:id/token (rotate-on-call, shown once) → POST /:id/finalize. Lifecycle confirmations (/started, /stopped, /resizing, /resized, /terminated) are compare-and-set on the row version and replay-safe with an Idempotency-Key. DELETE tombstones and is refused while an instance is attached.

Sizing is one pure function: POST /api/devboxes/size takes the profile from scan.py and answers the class, the all-in monthly price (compute + storage + public IPv4, running and stopped) and the reasons. The agent-facing surface is the pitchstation-devbox skill with its devbox.py CLI; code moves with git (the box is remote devbox; never rsync --delete, never force). Your dev instances and their cost to date: /devboxes.html. Full spec: /api/openapi-devboxes.json. Gated on devbox.manage, explicit grant only; team members can read boxes shared with their team.

Error catalog

HTTPCodeMeaning
401 / 403—Token missing, revoked, or lacks the create.invoice capability.
400BAD_EXTERNAL_REFRef over 128 chars or outside A-Z a-z 0-9 . _ : / -.
400TEST_IMMUTABLE / REF_IMMUTABLEThe test flag and external_ref are fixed at creation.
409REF_PAYLOAD_MISMATCHSame external_ref, different amount/recipient — fix the caller, don't retry.
409NO_ITEMS / ZERO_TOTALsend:true needs at least one line item and a positive total.
409LOCKED_PAID / LOCKED_PARTIALPaid invoices are records — correct via credit note.
400PAID_REQUIRES_SEND / BAD_PAID_AMOUNT / BAD_METADATAReceipt mode needs send:true; paid amount within the total; metadata within its caps.
404BAD_ISSUER / COLLECT_OFFissuer_id not yours; Pay-now not enabled for this invoice.
409NO_GATEWAY_PAYMENT / NOT_PAYABLE / NOTHING_DUERefund needs a Stripe-settled payment; checkout needs a sent invoice with a balance.
400CURRENCY_UNSUPPORTED_FOR_COLLECTZero-decimal currency amount not a whole unit — fix the cents.
429SEND_CAPDaily send cap for your plan (20 free / 200 pro / 1000 team). Deliberate: it bounds what a leaked token can email. Contact support to raise.
503PDF_RENDER_OFF / PDF_BUSYRenderer disabled or briefly saturated — retry later or skip the PDF.

Rules worth knowing

Integer centsNever send floats. unit_cents: 250000 is $2,500.00. Totals are recomputed server-side from items + tax − discount.
Delivery gatesA client email matching a PitchStation account gets an account-locked link (no password); anyone else gets link + password in the same email, rotating on resend.
NumberingGap-free per account, assigned at send (INV-2026-0042, prefix configurable). Drafts are unnumbered; voids keep their number.
RemindersAutomatic at due−3 / due / +7 / +14, stopping on paid. Per-invoice opt-out.
Rate limitsSend caps above; webhook endpoints max 3 per account. Standard API limits apply to bursts.
PitchStation · Invoice & Websites APIs · spec version 2026-07 · signed-in API reference with all products: /api-docs.html · questions: hello@pitchstation.ai