VeveeGuide · 8 min read
Guide · 8 min read

Send revenue from your payment webhook to Vevee

Record paid orders and refunds with vevee.revenue.order and vevee.revenue.refund so every channel and campaign gets a revenue column. Covers amounts in minor units net of tax, idempotency on the provider id, refunds that arrive early, and a webhook that never fails because of analytics.

Last updated: 2026-10-28

Attribution answers "where did this signup come from". Revenue answers the question you actually care about: which source brought people who paid. Vevee credits each order to the person's first touch, so once orders flow in, the Traffic page shows revenue and revenue per visit by channel, campaign, country and device.

You send orders from the payment webhook you already have, with a secret key. This guide shows the two calls, the rules for amounts and retries, and the error handling a study app settled on so an analytics outage can never block a subscription from being granted.

Step-by-step

1. Use a secret key on the server

vevee.revenue is secret key only. With a pk_ key both methods throw a VeveeError with code requires_secret_key before any request is made. Use the same client you use for identify, and the same distinctId: revenue is attached to that person and, through them, to their first touch.

import { createClient } from "@vevee/sdk";

export const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! }); // sk_live_ or sk_test_

2. Record the order when it is paid

Send amount as an integer in the currency's minor unit: cents for USD and EUR, whole yen for JPY. Send it after discounts and before tax, so orders and refunds stay comparable across countries with different tax rates. netAmount is optional and defaults to amount, so leave it out when amount is already the net figure. currency can be lower case; the route upper-cases it. billingReason is one of purchase, subscription_create, subscription_cycle or subscription_update.

type BillingReason = "purchase" | "subscription_create" | "subscription_cycle" | "subscription_update";

const result = await vevee.revenue.order({
  distinctId: order.customer.externalId,  // the id you pass to identify()
  orderId: order.id,                      // the provider's order id, used as idempotency key
  amount: order.netAmount,                // minor units, after discounts, before tax
  currency: order.currency,               // "eur", "usd"
  billingReason: order.billingReason as BillingReason,
  product: order.product?.name,
  createdAt: order.createdAt,             // optional; use it when replaying history
});
// result: { orderId, stored, duplicate, newPaying }

3. Record refunds once they succeed

Call revenue.refund when the provider reports a refund as succeeded, not when it is requested. The refund amount should exclude tax too. refundId is its own idempotency key, and the applied amount is capped at what is left of the order (appliedAmount in the result tells you what was applied).

const r = await vevee.revenue.refund({
  orderId: refund.orderId,
  refundId: refund.id,        // idempotency key
  amount: refund.amount,      // minor units, before tax
  createdAt: refund.createdAt,
});
// r: { refundId, stored, duplicate, appliedAmount }

4. Never let analytics fail the webhook

Payment webhooks usually do more than record revenue: the same handler grants the subscription. If a Vevee call throws and the handler returns an error, the provider retries the whole webhook, and a short analytics outage turns into duplicate grants or a stuck customer. The study app wraps every Vevee call so it logs and returns instead of throwing. Retries are safe anyway, because orderId and refundId make each call idempotent: a redelivered webhook returns the first result with duplicate: true and counts once.

import { VeveeError } from "@vevee/sdk";

async function safely<T>(label: string, fn: () => Promise<T>): Promise<T | VeveeError | null> {
  try {
    return await fn();
  } catch (err) {
    console.error("[vevee] " + label + " failed", err instanceof VeveeError ? err.code : err);
    return err instanceof VeveeError ? err : null;
  }
}

// inside the webhook handler, after granting the subscription
await safely("revenue.order", () => vevee.revenue.order({ /* ... */ }));
return new Response("ok");

5. Handle a refund that arrives before its order

A refund for an orderId Vevee has not seen yet throws not_found. Webhooks are not always delivered in order, so decide what happens then. If your refund handler does nothing else, it is fine to return an error for that one case and let the provider redeliver later. If it also revokes access, keep it from failing and queue the refund for a later retry instead. One exception: refunds for orders of people you have erased return not_found forever, so treat not_found after an erasure as done.

const res = await safely("revenue.refund", () => vevee.revenue.refund({ /* ... */ }));
if (res instanceof VeveeError && res.code === "not_found" && !(await wasErased(userId))) {
  await enqueueRefundRetry(refund.id); // or return 500 if this handler grants nothing
}

How orders show up in the dashboard

Each order is credited to the person's first touch. Some details matter when you read the numbers.

  • The dashboard shows USD. Each amount converts 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.
  • A subscription_cycle order (a renewal) never counts as a new paying customer. newPaying in the result tells you whether this order did.
  • Orders from people with no first touch land under Direct when the person was first seen after tracking started, and under "Before tracking" otherwise.

Replaying old orders

To load history, replay past orders with createdAt set to the original payment time. Because orderId is the idempotency key, running the replay twice does not double count, and live webhooks that overlap the replay are deduplicated the same way.

Orders you choose not to send

Revenue is personal data attached to a person record. The study app skips orders from accounts flagged as under 16: those accounts are opted out automatically and send no revenue events. Decide your own rule before you go live, and apply it in the webhook rather than after the fact.

Example result (mock data)

In an example month for a study app (made-up numbers): 1,070 visits across seven channels, $703.78 of revenue net of tax, $0.66 of revenue per visit across all channels and $1.64 per visit for AI search. The top campaign was a paid social campaign called oct-pricing. Numbers like these only appear once orders carry the right distinctId, so check that first if the revenue column stays empty.

Related docs

Method reference: /docs/methods/revenue. End-to-end setup: /docs/guides/traffic-attribution. Where the first touch comes from: /docs/methods/identify.

Frequently asked questions

Should amount be gross or net?

Send it after discounts and before tax, and send refunds before tax too. The SDK also accepts an optional netAmount (it defaults to amount), but if amount is already net you can leave it out.

What happens if my provider sends the same webhook twice?

Nothing extra. orderId and refundId are idempotency keys, so the second call returns the original result with duplicate: true and is counted once.

Can I send revenue from the browser?

No. Both methods need a secret key and throw requires_secret_key with a pk_ key. Revenue should come from the server that receives the payment provider's webhook anyway, since that is the only place you know the payment succeeded.

Revenue shows 0 but orders were sent. Why?

Check the currency first: an order in a currency with no exchange rate for its day yet is left out of the USD totals until the rate is loaded. Then check that distinctId matches the id you pass to identify.

Does a refund larger than the order break anything?

No. The applied amount is capped at what is left of the order, and the result's appliedAmount says how much was applied.

Other guides

8 min · Guide

How to track OpenAI API usage by user (with quotas, in real time)

Step-by-step guide to tracking OpenAI API usage per end-user with real-time quotas. Pure-TypeScript pattern using @vevee/sdk. Concurrency-safe, provider-agnostic, ten-minute integration.

7 min · Guide

How to implement per-user rate limits in your AI app

Per-user rate limits for AI apps need atomic enforcement, plan awareness, and refundable reservations. Here is the pattern that works under load - using @vevee/sdk and ~30 lines of code.

9 min · Guide

How to implement usage-based pricing for an AI product

Decide between per-token, per-action, and hybrid pricing. Implement quotas, refunds, and Stripe meter sync. Step-by-step with code, from a developer who has shipped all three models.

6 min · Guide

The reserve / commit / release pattern: atomic AI quota enforcement

Why naive AI usage metering breaks under concurrency, and the only correct pattern that fixes it. Reserve / commit / release explained, with full TypeScript example and the failure modes it prevents.

8 min · Guide

How to charge AI app users by token usage (with refunds and live balance)

Step-by-step: charge users for actual token consumption with pre-paid credits, post-paid invoicing, and a live balance display. Atomic reservations, accurate refunds, and Stripe meter sync.

10 min · Guide

Build a freemium image generator: end-to-end tutorial

Ship a freemium AI image generator with hard caps, upgrade prompts, real-time analytics, and a live "renders left" badge - using @vevee/sdk + your image provider of choice. Full code, ten-minute build.

12 min · Guide

Add cookieless traffic attribution to a Next.js app

Six copy-paste steps to attribute every signup and every order to the visit that brought it, with @vevee/sdk 1.14. The visit lives in browser memory, a server route forwards events with the secret key, and nothing is written to the device.

7 min · Guide

Build the two attribution funnels from JSON

Paste one JSON file into Vevee's funnel import to get a visit to signup funnel and a signup to paid funnel, both split by channel. Covers the split forms, step filters, units, and how to read the split table.

9 min · Guide

Test attribution with fake traffic in test mode

Check a cookieless attribution setup before real visitors arrive: a probe script that hits every call once, a seed script that fills test mode with believable traffic and refuses anything but an sk_test_ key, and a cross-check of the visit funnel against the Traffic page.