vevee.revenue.*POST /api/v1-13/revenue/orderPOST /api/v1-13/revenue/refundsk_live_
Send each paid order, and each refund, from your own payment webhook. Vevee credits the money to the person's first touch, so the Traffic page can show revenue, new paying customers and revenue per visit for every channel, campaign and landing page. Added in @vevee/sdk 1.14.0.
Secret key only. Both methods need an
sk_live_ or sk_test_ key. With a pk_* key the SDK throws VeveeError with code requires_secret_key before any request is sent. Call them from your server, never from the browser.Signature
vevee.revenue.order(o: RevenueOrder): Promise<RevenueOrderResult>
vevee.revenue.refund(r: RevenueRefund): Promise<RevenueRefundResult>
type RevenueBillingReason =
| 'purchase'
| 'subscription_create'
| 'subscription_cycle'
| 'subscription_update';
interface RevenueOrder {
distinctId: string; // the id you pass to identify()
orderId: string; // the provider's order id; idempotency key
amount: number; // integer, minor units (cents for USD)
netAmount?: number; // integer, minor units; defaults to amount
currency: string; // 3 letters, e.g. 'usd' or 'EUR'
billingReason?: RevenueBillingReason;
product?: string;
discountName?: string;
createdAt?: string; // ISO 8601 with offset, for replays
}
interface RevenueRefund {
orderId: string; // the order this refund belongs to
refundId: string; // the provider's refund id; idempotency key
amount: number; // integer, minor units, at least 1
createdAt?: string; // ISO 8601 with offset
}revenue.order()
| Name | Type | Description | |
|---|---|---|---|
distinctId | required | string | The person who paid. Use the same id you pass to identify(), so the order lands on the person who holds the first touch. 1 to 200 characters. |
orderId | required | string | Your payment provider's order id. It is the idempotency key: sending the same orderId again returns the first result with duplicate: true and counts once. 1 to 200 characters. |
amount | required | number | Integer in the currency's minor unit: cents for USD and EUR, whole yen for JPY, three decimals for KWD. 0 or more. |
netAmount | optional | number | Integer, minor units, after discounts. Defaults to amount and cannot exceed it. Refunds are capped at this value. |
currency | required | string | ISO 4217 code, exactly 3 letters. The server upper-cases it, so 'usd' works. |
billingReason | optional | RevenueBillingReason | purchase, subscription_create, subscription_cycle or subscription_update. A subscription_cycle order (a renewal) never counts as a new paying customer. |
product | optional | string | Product or plan name. Up to 200 characters. |
discountName | optional | string | Discount or coupon name. Up to 200 characters. |
createdAt | optional | string | ISO 8601 timestamp with an offset (for example 2026-10-01T10:00:00Z). Defaults to now. Use it to replay older orders. At most one hour in the future. The order is counted on the day of createdAt in the app's time zone. |
interface RevenueOrderResult {
orderId: string;
stored: boolean; // false when the person is pending erasure (nothing written)
duplicate: boolean; // true when this orderId was already recorded
newPaying: boolean; // true for the person's first non-renewal order
}revenue.refund()
| Name | Type | Description | |
|---|---|---|---|
orderId | required | string | The orderId you sent to revenue.order(). |
refundId | required | string | Your provider's refund id. Idempotency key, like orderId. |
amount | required | number | Integer, minor units, at least 1. The applied amount is capped at what is left of the order's net amount after earlier refunds. |
createdAt | optional | string | ISO 8601 with an offset. Defaults to now. At most one hour in the future. |
interface RevenueRefundResult {
refundId: string;
stored: boolean;
duplicate: boolean; // true when this refundId was already recorded
appliedAmount: number; // what was actually applied after the cap
}Example: a payment webhook
The shape of the webhook payload depends on your provider. The calls are the same for any of them. Send pre-tax amounts on both sides, so orders and refunds stay comparable.
// app/api/webhooks/payments/route.ts (server only)
import { createClient } from '@vevee/sdk';
const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });
// Called after you verified the provider's signature.
export async function onOrderPaid(order: {
id: string;
userId: string;
netAmount: number; // cents, after discounts, before tax
currency: string;
product?: string;
createdAt: string;
}) {
await vevee.revenue.order({
distinctId: order.userId,
orderId: order.id,
amount: order.netAmount,
currency: order.currency,
billingReason: 'subscription_create',
...(order.product ? { product: order.product } : {}),
createdAt: order.createdAt,
});
}
// Only once the refund has succeeded at the provider.
export async function onRefundSucceeded(refund: {
id: string;
orderId: string;
amount: number; // cents, before tax
createdAt: string;
}) {
// An unknown orderId throws VeveeError 'not_found'. Letting it propagate
// makes the provider redeliver the webhook after the order has been sent.
await vevee.revenue.refund({
orderId: refund.orderId,
refundId: refund.id,
amount: refund.amount,
createdAt: refund.createdAt,
});
}Decide what an outage should do. If the same webhook also grants the subscription, you may prefer to catch every Vevee error and log it, so an analytics outage never makes the provider redeliver a webhook that already did its real work. Because
orderId and refundId are idempotency keys, a redelivered webhook is always safe to send again.Rules
- Minor units.Amounts are integers in the currency's smallest unit. Pass them exactly as the provider charged them. A float or a negative number fails with
invalid_request. - Idempotency. A retried
orderIdorrefundIdreturns the original result withduplicate: trueand is counted once. - Refund cap.Refunds are capped at what is left of the order's net amount.
appliedAmountsays what was applied; it can be 0 for an order that was already fully refunded. - Unknown order. A refund for an
orderIdVevee has not seen throwsnot_found(404). Retry after the order has been sent. Refunds for orders of erased people returnnot_foundforever, so treatnot_foundafter an erasure as done. - Renewals.
billingReason: 'subscription_cycle'orders add revenue but never count as new paying customers. - Who gets the credit.Revenue is credited to the person's first touch. A person with no first touch lands under Direct when they were first seen after attribution tracking started, and under “Before tracking” otherwise.
- Privacy gates. An order for a person pending erasure is not stored (
stored: false). An order for an opted-out person is recorded against their pseudonym, which has no first touch. - Currency conversion.The dashboard shows USD. Each amount is converted with the daily ECB reference rate of its day (or the latest earlier day, for weekends and holidays). Currencies the ECB does not publish are left out of the totals and counted in a “not converted” note. Rates are loaded once a day, so a brand new order can show as not converted until that day's rate is in.
Errors
requires_secret_key(403): called with apk_*key. Thrown by the SDK before any request.invalid_request(400): a missing or malformed field, a non-integer amount,netAmountaboveamount, a currency that is not 3 letters, or acreatedAtmore than an hour in the future.not_found(404):refund()for an order that was never sent.analytics_quota_exceeded(429): the workspace is over its analytics quota.test_quota_exceeded(429): the sandbox quota is exhausted.invalid_key(401): bad or revoked API key.
Related: Cookieless traffic attribution (the full Next.js walkthrough), identify() first touch, Channels.