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()

NameTypeDescription
distinctIdrequiredstringThe 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.
orderIdrequiredstringYour 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.
amountrequirednumberInteger in the currency's minor unit: cents for USD and EUR, whole yen for JPY, three decimals for KWD. 0 or more.
netAmountoptionalnumberInteger, minor units, after discounts. Defaults to amount and cannot exceed it. Refunds are capped at this value.
currencyrequiredstringISO 4217 code, exactly 3 letters. The server upper-cases it, so 'usd' works.
billingReasonoptionalRevenueBillingReasonpurchase, subscription_create, subscription_cycle or subscription_update. A subscription_cycle order (a renewal) never counts as a new paying customer.
productoptionalstringProduct or plan name. Up to 200 characters.
discountNameoptionalstringDiscount or coupon name. Up to 200 characters.
createdAtoptionalstringISO 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()

NameTypeDescription
orderIdrequiredstringThe orderId you sent to revenue.order().
refundIdrequiredstringYour provider's refund id. Idempotency key, like orderId.
amountrequirednumberInteger, minor units, at least 1. The applied amount is capped at what is left of the order's net amount after earlier refunds.
createdAtoptionalstringISO 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,
  });
}
i
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 orderId or refundId returns the original result with duplicate: true and is counted once.
  • Refund cap.Refunds are capped at what is left of the order's net amount. appliedAmount says what was applied; it can be 0 for an order that was already fully refunded.
  • Unknown order. A refund for an orderId Vevee has not seen throws not_found (404). Retry after the order has been sent. Refunds for orders of erased people return not_found forever, so treat not_found after 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 a pk_* key. Thrown by the SDK before any request.
  • invalid_request (400): a missing or malformed field, a non-integer amount, netAmount above amount, a currency that is not 3 letters, or a createdAt more 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.
i
Related: Cookieless traffic attribution (the full Next.js walkthrough), identify() first touch, Channels.