---
title: "Revenue attribution"
description: "Connect live payment-provider access and attribute verified revenue to its originating session."
documentation: "https://tracwell.app/docs/revenue"
markdown: "https://tracwell.app/docs/revenue.md"
---

# Revenue attribution

> Connect live payment-provider access and attribute verified revenue to its originating session.

## How the flow works

To record checkout creation before payment, use [server custom events](https://tracwell.app/docs/server-events.md). A checkout click, a created checkout session, and a verified payment are separate stages.

1. Collect a Product-mode session with the Browser or React Native SDK.
2. Pass its opaque anonymous and session IDs to a server-created checkout.
3. Store those IDs in the payment provider's checkout metadata.
4. Verify the provider webhook or another trusted payment result.
5. Send the verified purchase or refund with the website's server key.

Payment-provider connections validate and securely save live read access. A connection alone does not create revenue events; send each verified purchase or refund through the server revenue endpoint.

## Connect a payment provider

Select the website, then open **Settings → Revenue** and connect one provider:

| Provider | Credential |
| --- | --- |
| Stripe | In **Dashboard → Developers → API keys**, create a live restricted key beginning with `rk_live_` and allow read access to Account, PaymentIntents, and Refunds |
| Dodo Payments | In **Dashboard → Developer → API**, create a live API key with **Enable write access** turned off |
| Polar | In the organization settings, create an access token with `organizations:read` and `orders:read` scopes |

Tracwell tests the provider API before encrypting and saving the credential. It rejects test-mode credentials and full-access Stripe secret keys.

Your application still keeps its own provider checkout secret and webhook signing secret. The read-only credential connected to Tracwell cannot be retrieved or reused by your application.

## Create a server key

In **Settings → Revenue**, generate a server key for the same website. The website must be active and use Product mode. Save the one-time key as a server-only secret such as `TRACWELL_SERVER_KEY`.

A server key starts with `tw_sk_` and writes to one website. Never put it in browser code, a mobile app, checkout metadata, logs, or source control.

## Attach the originating session

Call `getSession()` immediately before checkout and send the opaque IDs to your checkout endpoint:

```ts
const session = analytics.getSession();

await fetch("/api/checkout", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    attribution: session
      ? {
          anonymousId: session.anonymousId,
          sessionId: session.sessionId,
        }
      : undefined,
  }),
});
```

Validate and length-limit the values on your server, then store them in provider metadata using stable names such as `tracwell_anonymous_id` and `tracwell_session_id`. The React Native SDK exposes the same fields.

Session IDs are attribution context, not authorization. Set revenue `user_id` from the authenticated account or verified provider payload, never from the client request.

## Send revenue to Tracwell

Use this helper after the provider-specific mapping below. Amounts use the currency's minor unit, so `900` means $9.00 for USD.

```ts
type RevenueInput = {
  amountMinor: number;
  anonymousId?: string;
  currency: string;
  eventId: string; // A UUID kept stable across retries.
  kind: "purchase" | "refund";
  sessionId?: string;
  timestamp: string;
  transactionId: string;
  userId: string;
};

export async function stableRevenueEventId(key: string) {
  const bytes = new Uint8Array(
    await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key)),
  ).slice(0, 16);

  bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x50;
  bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
  const hex = Array.from(bytes, (byte) =>
    byte.toString(16).padStart(2, "0"),
  );

  return [
    hex.slice(0, 4).join(""),
    hex.slice(4, 6).join(""),
    hex.slice(6, 8).join(""),
    hex.slice(8, 10).join(""),
    hex.slice(10).join(""),
  ].join("-");
}

export async function sendTracwellRevenue(input: RevenueInput) {
  const response = await fetch(
    "https://collect.tracwell.app/v1/server/revenue",
    {
      method: "POST",
      headers: {
        authorization: `Bearer ${env.TRACWELL_SERVER_KEY}`,
        "content-type": "application/json",
      },
      body: JSON.stringify({
        batch_version: 1,
        batch_id: crypto.randomUUID(),
        sent_at: new Date().toISOString(),
        sdk: { name: "tracwell-server", version: "0.1.0" },
        events: [{
          schema_version: 1,
          event_id: input.eventId,
          transaction_id: input.transactionId,
          timestamp: input.timestamp,
          kind: input.kind,
          amount_minor: input.amountMinor,
          currency: input.currency.toUpperCase(),
          user_id: input.userId,
          ...(input.anonymousId
            ? { anonymous_id: input.anonymousId }
            : {}),
          ...(input.sessionId ? { session_id: input.sessionId } : {}),
        }],
      }),
    },
  );

  if (response.status !== 202) {
    throw new Error(`Revenue delivery failed: ${response.status}`);
  }
}
```

- Derive the same event UUID from the provider event identity for every retry.
- Use `purchase` with a positive amount for a successful payment.
- Use `refund` with a positive amount and the provider's stable refund ID as a separate transaction.
- A `202` response means the Collector accepted the batch into its durable queue.

## Stripe

For one-time Checkout, copy the Tracwell fields into both Checkout Session `metadata` and `payment_intent_data.metadata`. The first copy remains on `checkout.session.completed`; the second is available on `payment_intent.succeeded` and the resulting Charge.

```ts
const metadata = {
  tracwell_user_id: authenticatedUser.id,
  ...(attribution
    ? {
        tracwell_anonymous_id: attribution.anonymousId,
        tracwell_session_id: attribution.sessionId,
      }
    : {}),
};

const checkout = await stripe.checkout.sessions.create({
  mode: "payment",
  line_items: [{ price: env.STRIPE_PRICE_ID, quantity: 1 }],
  success_url: "https://example.com/checkout/success",
  cancel_url: "https://example.com/pricing",
  client_reference_id: authenticatedUser.id,
  metadata,
  payment_intent_data: { metadata },
});

// Run only after stripe.webhooks.constructEvent() verifies the raw body.
if (event.type === "payment_intent.succeeded") {
  const intent = event.data.object;

  await sendTracwellRevenue({
    eventId: await stableRevenueEventId(`stripe:${event.id}`),
    transactionId: intent.id,
    timestamp: new Date(event.created * 1000).toISOString(),
    kind: "purchase",
    amountMinor: intent.amount_received,
    currency: intent.currency,
    userId: intent.metadata.tracwell_user_id,
    anonymousId: intent.metadata.tracwell_anonymous_id,
    sessionId: intent.metadata.tracwell_session_id,
  });
}
```

| Tracwell field | Stripe source |
| --- | --- |
| Successful payment | Verified `payment_intent.succeeded` |
| `transaction_id` | `PaymentIntent.id` |
| `amount_minor` | `PaymentIntent.amount_received` |
| `currency` | `PaymentIntent.currency` |
| `user_id` | Server-created `tracwell_user_id` |

For refunds, handle a successful `refund.created` or `refund.updated`, use `Refund.id` and `Refund.amount`, and retrieve the original PaymentIntent or Charge for its Tracwell metadata. For subscriptions, put the same fields in `subscription_data.metadata` and map each paid invoice as its own revenue transaction.

## Dodo Payments

Add the Tracwell fields directly to Checkout Session `metadata`. Dodo includes that metadata in payment API responses and webhook events.

```ts
const checkout = await dodo.checkoutSessions.create({
  product_cart: [{ product_id: env.DODO_PRODUCT_ID, quantity: 1 }],
  customer: {
    email: authenticatedUser.email,
    name: authenticatedUser.name,
  },
  return_url: "https://example.com/checkout/success",
  metadata: {
    tracwell_user_id: authenticatedUser.id,
    ...(attribution
      ? {
          tracwell_anonymous_id: attribution.anonymousId,
          tracwell_session_id: attribution.sessionId,
        }
      : {}),
  },
});

// Run only after Standard Webhooks verifies the raw body and headers.
if (event.type === "payment.succeeded") {
  const payment = event.data;

  await sendTracwellRevenue({
    eventId: await stableRevenueEventId(
      `dodo:${webhookHeaders["webhook-id"]}`,
    ),
    transactionId: payment.payment_id,
    timestamp: payment.created_at,
    kind: "purchase",
    amountMinor: payment.settlement_amount,
    currency: payment.settlement_currency,
    userId:
      payment.metadata.tracwell_user_id ?? payment.customer.customer_id,
    anonymousId: payment.metadata.tracwell_anonymous_id,
    sessionId: payment.metadata.tracwell_session_id,
  });
}
```

| Tracwell field | Dodo source |
| --- | --- |
| Successful payment | Verified `payment.succeeded` |
| `transaction_id` | `payment_id` |
| `amount_minor` | `settlement_amount` |
| `currency` | `settlement_currency` |
| `user_id` | Server-created `tracwell_user_id`, then `customer.customer_id` |

For refunds, handle `refund.succeeded`, use `refund_id`, `amount`, and `currency`, then retrieve the original payment by `payment_id` for its Tracwell metadata. Initial subscription charges and renewals both emit `payment.succeeded`; map every `payment_id` separately.

## Polar

Add the Tracwell fields to Checkout `metadata` and pass the authenticated account ID as `externalCustomerId`. Checkout metadata propagates to the resulting Order.

```ts
const checkout = await polar.checkouts.create({
  products: [env.POLAR_PRODUCT_ID],
  externalCustomerId: authenticatedUser.id,
  successUrl: "https://example.com/checkout/success",
  metadata: attribution
    ? {
        tracwell_anonymous_id: attribution.anonymousId,
        tracwell_session_id: attribution.sessionId,
      }
    : {},
});

// Run only after Polar verifies the webhook signature.
if (event.type === "order.paid") {
  const order = event.data;

  await sendTracwellRevenue({
    eventId: await stableRevenueEventId(`polar:${order.id}:paid`),
    transactionId: order.id,
    timestamp: order.created_at,
    kind: "purchase",
    amountMinor: order.total_amount,
    currency: order.currency,
    userId: order.customer.external_id ?? order.customer_id,
    anonymousId: order.metadata.tracwell_anonymous_id,
    sessionId: order.metadata.tracwell_session_id,
  });
}
```

| Tracwell field | Polar source |
| --- | --- |
| Successful payment | Verified `order.paid` |
| `transaction_id` | `Order.id` |
| `amount_minor` | `Order.total_amount` |
| `currency` | `Order.currency` |
| `user_id` | `Order.customer.external_id`, then `customer_id` |

For refunds, handle `refund.updated` only when its status is `succeeded`. Use `Refund.id`, `amount`, and `currency`, then retrieve `order_id` for its Tracwell metadata. Polar creates an Order for the initial charge and every renewal, and every successful one arrives through `order.paid`.

## Verify attribution

1. Start from a visit with a referrer or UTM campaign and complete a live checkout.
2. Confirm the server revenue request returns `202`.
3. Open the same website's Revenue report and confirm the amount under its source, campaign, and landing page.

Tracwell uses `first_touch_session_v1`. Revenue without a matching session remains recorded but cannot inherit that visit's acquisition context.
