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
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
| 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. |
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
| 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
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
Supapulse resolves the visit in this order: visitor_fingerprint, session_id, then the touch stored for the customer_id. See Attribution.
Responses from /api/ingest
| 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
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
POST /api/fingerprint HTTP/1.1
Host: supapulse.app
Content-Type: text/plain;charset=UTF-8
{ "token": "pub_your_public_token" }{ "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
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 for what each event becomes.
The @supapulse/revenue SDK
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
| 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
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>;
}