# API reference

URL: https://supapulse.app/docs/revenue/api-reference

The revenue and identify events the ingest API accepts, the fingerprint endpoint, the Stripe webhook, what each stores, and the @supapulse/revenue SDK surface.

## Revenue request [#revenue-request]

```http
POST /api/ingest HTTP/1.1
Host: supapulse.app
Content-Type: application/json
X-API-Token: sec_your_secret_token

{
  "type": "revenue",
  "data": {
    "customer_id": "user_123",
    "amount": 49.90,
    "currency": "USD",
    "transaction_id": "in_1Q8xYz",
    "event_type": "renewal",
    "payment_processor": "stripe"
  }
}
```

Revenue needs the secret token. A public token gets `422`, because `revenue` isn't an allowed type for it.

### Fields [#fields]

| Field | Type | Required | Default | Rule |
| --- | --- | --- | --- | --- |
| `customer_id` | string | Yes | none | Up to 255 characters. |
| `amount` | number | Yes | none | Major units, from `0` to `99999999.99`. Never negative. |
| `transaction_id` | string | Yes | none | Up to 255 characters. Unique per site and `event_type`. |
| `event_type` | string | No | `purchase` | One of the [event types](#event-types). |
| `currency` | string | No | `USD` | Three letters. Uppercased. Not converted. |
| `visitor_fingerprint` | string | No | `null` | Up to 64 characters. From `getVisitorFingerprint()`. |
| `session_id` | string | No | `null` | Up to 64 characters. From `getSessionId()`. |
| `subscription_id` | string | No | `null` | Up to 255 characters. `customer_subscription_id` is accepted as an alias. |
| `mrr_impact` | number | No | `0` | Change in monthly recurring revenue, from `-99999999.99` to `99999999.99`. |
| `is_recurring` | boolean | No | by event type | Defaults to true for `subscription`, `renewal`, `upgrade`, `downgrade` and `churn`. |
| `metadata` | object | No | `null` | Up to 50 keys. Stored as JSON. |
| `payment_processor` | string | No | `stripe` | Up to 32 characters. Free text. |
| `occurred_at` | date | No | now | ISO 8601. Not more than a day in the future. |

Fields not listed are ignored.

### Event types [#event-types]

| Type | Stored amount | `mrr_impact` |
| --- | --- | --- |
| `purchase` | As sent | As sent |
| `subscription` | As sent | Positive |
| `renewal` | As sent | As sent, or `0` once the subscription has MRR |
| `upgrade` | As sent | Positive |
| `downgrade` | As sent | Negative |
| `churn` | As sent | Negative; with `subscription_id`, the MRR recorded for it |
| `refund` | Negative | As sent |

Send `amount` as a positive number. For `refund`, Supapulse stores it as negative so revenue is net of refunds. `mrr_impact` signs are fixed for you: send the size of the change.

### Idempotency [#idempotency]

A revenue event is identified by the site, `transaction_id` and `event_type`. Sending the same combination again returns `202` and stores nothing, so retries are safe. The same `transaction_id` with a different `event_type` is a different event.

### Attribution [#attribution]

Supapulse resolves the visit in this order: `visitor_fingerprint`, `session_id`, then the touch stored for the `customer_id`. See [Attribution](https://supapulse.app/docs/revenue/attribution).

## Responses from /api/ingest [#responses-from-apiingest]

| Status | Body | Meaning |
| --- | --- | --- |
| `202` | `{ "success": true }` | Validated and queued. The worker stores it shortly after. |
| `401` | `{ "error": "Missing API token" }` | No `X-API-Token` header and no `token` in the body. |
| `401` | `{ "error": "Invalid token format" }` | The token doesn't start with `pub_` or `sec_`. |
| `401` | `{ "error": "Invalid API token" }` | No active site has this token. |
| `402` | `{ "error": "…", "code": "plan_inactive" }` | The site's team has no active plan. |
| `402` | `{ "error": "…", "code": "quota_exceeded" }` | Monthly event limit reached. Never returned for `revenue`. |
| `403` | `{ "error": "Origin not allowed" }` | An `Origin` or `Referer` header was sent and doesn't match the site's domain. Server-side requests usually send neither. |
| `422` | Validation errors | A field broke a rule above, `type` or `data` is missing, or `type` isn't allowed for the token (a public token sending `revenue`). |
| `429` | | More than 2,000 requests in an hour from one IP address. Wait for `Retry-After`. |

## Identify request [#identify-request]

```http
POST /api/ingest HTTP/1.1
Host: supapulse.app
Content-Type: text/plain;charset=UTF-8

{
  "type": "identify",
  "token": "pub_your_public_token",
  "data": { "customer_id": "user_123", "session_id": "6f1c2e9a-…" }
}
```

| Field | Type | Required | Rule |
| --- | --- | --- | --- |
| `customer_id` | string | Yes | Up to 255 characters. |
| `session_id` | string | No | Up to 64 characters. |
| `visitor_fingerprint` | string | No | Up to 64 characters. Secret token only. |
| `source_customer_id` | string | No | Up to 255 characters. Secret token only. Copies the attribution already stored for another customer ID. |

Identify works with the public or the secret token. With a public token, `visitor_fingerprint` and `source_customer_id` are discarded and the fingerprint is built from the request's IP address and user agent. Identify events never count toward usage.

## Fingerprint request [#fingerprint-request]

```http
POST /api/fingerprint HTTP/1.1
Host: supapulse.app
Content-Type: text/plain;charset=UTF-8

{ "token": "pub_your_public_token" }
```

```json title="200 OK"
{ "visitor_fingerprint": "9b74c9897bac770ffc029102a200c5de…" }
```

The value is the same hash Supapulse stores on the visitor's pageviews. Call it from the browser: it's built from the caller's IP address and user agent. It returns `401`, `403` and `402` like `/api/ingest`, but is never limited by the monthly event quota.

## Stripe webhook [#stripe-webhook]

```text
POST https://supapulse.app/api/webhooks/stripe/{publicToken}
```

Stripe calls this endpoint. It needs a signing secret saved in the site's settings.

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `{ "received": true }` | Verified and queued. |
| `200` | `{ "ignored": "plan_inactive" }` | Verified, but the team has no active plan. Nothing was stored. |
| `400` | `{ "error": "Invalid signature" }` | The signature didn't verify, or the timestamp is older than 5 minutes. |
| `404` | `{ "error": "Not found" }` | Unknown public token, or no signing secret saved for the site. |
| `429` | | Over the ingest rate limit. |

See the [Stripe guide](https://supapulse.app/docs/guides/stripe) for what each event becomes.

## The @supapulse/revenue SDK [#the-supapulserevenue-sdk]

```ts
import { Revenue, type RevenueConfig, type RevenueData } from '@supapulse/revenue';

const revenue = new Revenue(config: RevenueConfig);
await revenue.track(data: RevenueData);
```

`track()` validates its input, then posts the same request as above with the token in both the `X-API-Token` header and the body. It retries network errors, `5xx` and `429` responses up to `maxRetries` times, then throws. Other `4xx` responses, including `402` and `422`, throw immediately with the status and body.

### What track() validates [#what-track-validates]

| Check | Error message |
| --- | --- |
| `customer_id` is set | `customer_id is required` |
| `amount` is a number ≥ 0 | `amount must be a positive number` |
| `transaction_id` is set | `transaction_id is required` |
| `event_type`, if set, is one of the seven types | `event_type must be one of: …` |
| `mrr_impact`, if set, is a number | `mrr_impact must be a number` |
| `occurred_at`, if set, is a valid date | `occurred_at must be a valid date` |

`visitor_fingerprint` and `session_id` are optional.

### Types [#types]

```ts
interface RevenueConfig {
  apiToken: string;
  endpoint?: string;
  debug?: boolean;
  maxRetries?: number;
  retryBackoff?: number;
}

interface RevenueData {
  visitor_fingerprint?: string;
  session_id?: string;
  customer_id: string;
  amount: number;
  currency?: string;
  transaction_id: string;
  payment_processor?: string;
  event_type?: 'purchase' | 'subscription' | 'renewal' | 'upgrade' | 'downgrade' | 'churn' | 'refund';
  occurred_at?: Date | string;
  subscription_id?: string;
  /** @deprecated Use `subscription_id`. */
  customer_subscription_id?: string;
  mrr_impact?: number;
  is_recurring?: boolean;
  metadata?: Record<string, any>;
}
```
