# Billing webhooks (Stripe & Polar)

MRR and revenue with **zero product code**: paste your webhook signing
secret in the dashboard, point the biller at your endpoint, done.

| Provider | Endpoint |
|---|---|
| Stripe | `https://in.datagauge.dev/v1/webhooks/stripe/{project_id}` |
| Polar | `https://in.datagauge.dev/v1/webhooks/polar/{project_id}` |

`{project_id}` is the app's `proj_…` project id, shown on the app's page in the
dashboard and returned as **`public_id`** by
[`GET /v1/apps`](/docs/api/apps#one-identifier-three-names). It is the same value
metric reads call `project`. It is *not* the app's `app_id`, which is a
different string in the same JSON object; a `k17b2c3…` in this path is a `404`.

Signatures are verified with your per-project secret (Stripe
`Stripe-Signature`, Polar Standard-Webhooks), with a 5-minute replay
window. Secrets are stored server-side and never shown again. You can set
them headlessly too, with the [Control API](/docs/api/apps):
`PUT /v1/apps/{id}/webhooks/{provider}`.

## Rehearsing against your biller's test mode

Each plane has its own endpoint and its own signing secret. Point your biller's
**test** webhook at your app's `test_public_id`, and paste the secret from that
same test endpoint:

| Plane | Endpoint | Secret set by |
|---|---|---|
| live | `…/v1/webhooks/stripe/{public_id}` | the dashboard in live mode, or a `dg_live_` token |
| test | `…/v1/webhooks/stripe/{test_public_id}` | the dashboard in test mode, or a `dg_test_` token |

The two never meet. A test secret cannot verify a live delivery, and a live one
cannot verify a test delivery, because they are separate entries addressed by
separate project ids. So a test subscription's `invoice.paid` lands in your test
metrics and moves nothing you would put in front of an investor.

The plane comes from the credential, never from the request: a `dg_test_` token
writes the test secret and has no way to address the live one. If you send
`environment` in the body it must match your token's plane, or the write is a
`400`. It is an assertion, not a switch.

A brand-new app has no test project until something asks for one, so
`test_public_id` is `null` and the endpoint URL does not exist yet. Saving a test
signing secret creates it, in the dashboard and over the Control API alike. No
key is minted in the process.

Stripe issues a different `whsec_…` for every endpoint you create, including the
one in test mode. Copying the live secret into the test plane produces an
endpoint that verifies nothing: every delivery fails its signature check, and
Stripe disables the endpoint after retrying for days.

## What gets mapped

- **Stripe**: `invoice.paid` → a payment; `customer.subscription.*` → the
  subscription object, MRR monthly-normalized from the line items
  (annual ÷ 12, quantity-aware).
- **Polar**: `order.paid` → a payment; `subscription.*` → the subscription
  object.

Event types we don't consume are acknowledged with `200`, so your biller
never retry-spams.

## Retries can't double-count

Billers deliver at-least-once. Every mapped payment carries a stable id
derived from the biller's object (`stripe:in_123`), and the money store
dedupes on it structurally. Send the same `invoice.paid` five times:
revenue moves once.

## Oversized fields are shortened, never dropped

Billers put customer-controlled strings in their objects, and nothing bounds
how long they get. If a field arrives too long to store, we shorten it,
mark it with `#truncated#`, and keep the payment. The response says which
fields were touched:

```json
{ "ok": true, "rows": 1, "truncated": ["payments.user"] }
```

Refusing the delivery instead would only make your biller retry the same
bytes until it gave up and disabled the endpoint, and you would be missing
the money, not just the id.

A shortened value keeps a fingerprint of the whole original (its length and
a 64-bit hash), so two different oversized ids stay two different payments
unless they match on both. That is not the same as "never": it is a
fingerprint, not the value. Nothing a biller sends can reach it, since Stripe
and Polar ids are far shorter than the limit and are never truncated at all.

## While your own subscription is not in good standing

If your DataGauge account is suspended, either for an unpaid invoice or because
your subscription has ended, deliveries to these endpoints are verified and
acknowledged, but not stored:

```json
{ "ok": true, "rows": 0, "suspended": true, "code": "ingest_suspended" }
```

Still a `200`, deliberately. A failure status would not reach you; it would
make Stripe or Polar retry the delivery for days and then disable your
endpoint, which is a worse thing to be left with than a gap. Nothing already
stored is deleted, reads keep working, and storage resumes on the next
delivery once the invoice is paid or the subscription is restarted.

## Marketplaces / Stripe Connect

An invoice carrying an `application_fee_amount` is split automatically:
the **fee** is your revenue (`revenue` metric); the **gross** is volume
processed for someone else (`volume` metric). Other people's money never
inflates your portfolio.
