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 — scope it to create.invoice so a leaked token can touch nothing else. 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://www.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://www.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.

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://www.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://www.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"] },
      "contact": { "email": "alice@example.com", "website_url": "https://shop.example.com/?ref=12345" }
    },
    "assets": { "logo": "<base64>" }
  }'

Full OpenAPI 3 spec: /api/openapi-websites.json. Website-specific error codes: BAD_EXTERNAL_REF, BAD_METADATA, SITE_QUOTA (429, plan site cap), PUBLISH_QUOTA (429, daily live publishes), NOT_APPROVED (409, approval gate), SUBDOMAIN_TAKEN (409).

Error catalog

HTTPCodeMeaning
401 / 403Token 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