Browse documentation

Revenue attribution

Connect live payment-provider access, carry the originating session into checkout, and send verified purchases or refunds from your server.

How the flow works

To record checkout creation before payment, use server custom events. A checkout click, a created checkout session, and a verified payment are separate stages.

From visit to revenue
  1. 1

    Collect a Product-mode session

    The Browser or React Native SDK creates the anonymous and session IDs used for attribution.

  2. 2

    Attach the session to checkout

    Pass those opaque IDs to your server and store them in the payment provider's checkout metadata.

  3. 3

    Verify the payment

    Handle a signed provider webhook or another trusted server-side payment result.

  4. 4

    Send the revenue event

    Post the verified amount and saved session IDs 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 in Tracwell, then open Settings → Revenue. Choose Stripe, Dodo Payments, or Polar and paste a live, read-only credential. Tracwell tests the provider API before encrypting and saving it.

Required credential
Stripe
Open Stripe Dashboard → Developers → API keys, create a live restricted key beginning with rk_live_, and allow read access to Account, PaymentIntents, and Refunds.
Dodo Payments
Open Dodo Dashboard → Developer → API, create a live API key, and leave Enable write access turned off.
Polar
Open the Polar organization settings, create an organization access token, and grant organizations:read and orders:read.
Tracwell rejects test-mode credentials and full-access Stripe secret keys. Provider credentials belong to the selected website and are never returned after they are saved.
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. Copy the key when it appears and save it as a server-only secret such as TRACWELL_SERVER_KEY.

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

Attach the originating session

Use Product mode and call getSession() immediately before starting checkout. Send the returned opaque IDs to your checkout endpoint, then copy them into payment-provider metadata. The React Native SDK exposes the same fields.

Browser or React Native client
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 both values on your server before adding them to provider metadata. Keep the mapping stable, for exampletracwell_anonymous_id and tracwell_session_id, so the verified webhook can read them later.

Session IDs are attribution context, not proof of identity or payment. Set user_id from your authenticated account or verified provider payload, never from the client request.

Send revenue to Tracwell

Use one server helper after the provider-specific webhook mapping below. Amounts use the currency's minor unit: 900means $9.00 for USD.

Server revenue helper
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}`);
  }
}
Delivery rules
Stable event ID
Derive the same UUID from the provider event identity for every retry. Do not create a new random event ID while retrying.
Purchase
kind is purchase and amount_minor is a positive integer.
Refund
kind is refund; keep the amount positive and use the provider's stable refund ID as a separate transaction.
Accepted
202 means the Collector accepted the batch into its durable queue. Retry bounded transient failures without changing the event ID.

Stripe

For a one-time Checkout Session, copy the Tracwell fields into both Checkout Session metadata andpayment_intent_data.metadata. The first copy remains oncheckout.session.completed; the second is available onpayment_intent.succeeded and the resulting Charge.

Stripe Checkout and webhook
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,
  });
}
Stripe mapping
Successful payment
Verify the Stripe-Signature header, then handle payment_intent.succeeded.
Transaction
PaymentIntent.id
Amount
PaymentIntent.amount_received
Currency
PaymentIntent.currency
Customer
Use server-created tracwell_user_id; do not trust a user ID supplied by the browser.
Refund
Handle a successful refund.created or refund.updated, use Refund.id, Refund.amount, and retrieve the original PaymentIntent or Charge for its Tracwell metadata.
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 Sessionmetadata. Dodo includes that metadata in payment API responses and webhook events.

Dodo Checkout and webhook
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,
  });
}
Dodo mapping
Successful payment
Verify the Standard Webhooks headers against the raw request body, then handle payment.succeeded.
Transaction
payment_id
Amount
settlement_amount
Currency
settlement_currency
Customer
Use server-created tracwell_user_id, falling back to the verified customer.customer_id.
Refund
Handle refund.succeeded, use refund_id, amount, and currency, then retrieve the original payment by payment_id for its Tracwell metadata.
Subscriptions
Every successful initial or renewal charge also emits payment.succeeded, so map each 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.

Polar Checkout and webhook
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,
  });
}
Polar mapping
Successful payment
Verify the Polar webhook signature, then handle order.paid. Do not count order.created, because it can still be pending.
Transaction
Order.id
Amount
Order.total_amount
Currency
Order.currency
Customer
Prefer Order.customer.external_id, falling back to the verified customer_id.
Refund
Handle refund.updated only when status is succeeded. Use Refund.id, amount, and currency, then retrieve order_id for its Tracwell metadata.
Subscriptions
Polar creates an Order for the initial charge and every renewal. Each paid Order arrives through order.paid.

Verify attribution

  1. 1

    Complete a real checkout

    Start from a visit with a referrer or UTM campaign and finish the provider's live payment flow.

  2. 2

    Confirm server delivery

    Check that the server revenue request returns 202 and retain failures for retry.

  3. 3

    Open the Revenue report

    Select the same website and confirm the amount under its source, campaign, and landing page.

Tracwell uses first_touch_session_v1: the revenue event's session ID joins the payment to the first source, campaign, and landing page recorded for that session. Revenue without a matching session is still recorded but cannot inherit that visit's acquisition context.