---
title: "Stripe Webhook"
method: POST
path: "/webhooks/stripe"
tags: ["webhooks"]
---

# Stripe Webhook

`POST /webhooks/stripe`

## Request

- Base URL: `https://api.projectindi.com` (the document declares no server; this is the origin it was published from)
- URL: `https://api.projectindi.com/webhooks/stripe`
- Auth: none declared

Handle Stripe webhook events.

Accepts all events from Stripe but only processes:
- invoice.paid → NDIS invoice grants (RevenueCat 12-month) + recurring Pay It
  Forward contributions (pool credit per renewal).
- invoice.finalized → flips a pending NDIS row to 'invoiced' (our "Sent"), so
  finalizing in the Stripe dashboard updates our status the same as the admin
  Send button. Mirrors adminPortal's mark_invoice_sent; no grants. Also the
  entry point for a Stripe revision: on a row-miss we re-point the original's
  row onto the replacement (see _repoint_revised_ndis_invoice).
- invoice.sent → parallel internal notification to the receivables watcher
  (Stripe does not support CC/BCC on invoice emails).
- invoice.voided → marks the ndis_invoice_requests row as 'cancelled', unless
  the invoice was voided into a revision (latest_revision set).
- invoice.marked_uncollectible → marks the row as 'uncollectible'.
- checkout.session.completed → one-off Pay It Forward gifts (sponsor-a-family)
  when mode='payment'; when mode='subscription', claims family_units.billing_contact_id
  for the parent who paid (#8920).
- customer.subscription.created / .updated → mirrors the live subscription onto
  family_units: stripe_product_id so the account UI can render the plan,
  subscription_cancel_at when a cancellation is booked for the end of the term,
  and stripe_subscription_id so a later .deleted can tell this subscription from
  one that replaced it. Skipped for a subscription that is not the live one.
  Writes no entitlement - a scheduled cancellation is not a status change.
- customer.subscription.deleted → the term has ended, so this writes the
  entitlement: subscription_status='canceled' and subscription_expires_at, plus
  nulling entitled_from_app_user_id, and clears all three mirror columns.
  Nothing else writes a status for a Stripe-direct family; RevenueCat never
  sees one. Skipped entirely when the event names a subscription the family
  has already replaced.

Anything carrying `metadata.organisation_id` is a CLINIC's event and is routed
to `clinic/billing.py` before any of the above runs (tech plan §2.4): the
clinic path writes `organisation_billing` and `clinic_invoices` and nothing a
family reads, and the two must not braid. Handled there: checkout.session.completed,
the subscription created / updated / deleted mirror, and the four invoice events,
which record billed periods on the `clinic_invoices` ledger.

invoice.payment_failed is intentionally a no-op: Stripe leaves the invoice
in 'open' status on a failed attempt and only attempt_count increments, so
the canonical state hasn't changed. Keeping the row at 'invoiced' preserves
the duplicate-submission gate in api/src/api/subscription/routes.py
(status IN ('pending','invoiced')) so a parent cannot create a second
Stripe invoice while the first is still open. Admins see attempt_count in
the reconcile dialog. Future agents: do not "fix" this absence.

All other event types are acknowledged (200) but not processed.

## Response `200`

Successful Response

- unknown

---

[API](https://skmtc.dev/projectindi/apis/indi-agent-api-first-principles.md) - [All operations](https://skmtc.dev/projectindi/apis/indi-agent-api-first-principles/llms.txt) - [OpenAPI document](https://skmtc.dev/projectindi/apis/indi-agent-api-first-principles/revisions/25a7813e21fd?raw)
