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.
- 1
Collect a Product-mode session
The Browser or React Native SDK creates the anonymous and session IDs used for attribution.
- 2
Attach the session to checkout
Pass those opaque IDs to your server and store them in the payment provider's checkout metadata.
- 3
Verify the payment
Handle a signed provider webhook or another trusted server-side payment result.
- 4
Send the revenue event
Post the verified amount and saved session IDs with the website's server key.
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.
- 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:readandorders:read.
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.
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.
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.
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.
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}`);
}
}- 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
kindispurchaseandamount_minoris a positive integer.- Refund
kindisrefund; keep the amount positive and use the provider's stable refund ID as a separate transaction.- Accepted
202means 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.
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,
});
}- Successful payment
- Verify the
Stripe-Signatureheader, then handlepayment_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.createdorrefund.updated, useRefund.id,Refund.amount, and retrieve the original PaymentIntent or Charge for its Tracwell metadata. - Subscriptions
- Put the same fields in
subscription_data.metadataand 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.
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,
});
}- 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 verifiedcustomer.customer_id. - Refund
- Handle
refund.succeeded, userefund_id,amount, andcurrency, then retrieve the original payment bypayment_idfor its Tracwell metadata. - Subscriptions
- Every successful initial or renewal charge also emits
payment.succeeded, so map eachpayment_idseparately.
Polar
Add the Tracwell fields to Checkout metadata and pass the authenticated account ID as externalCustomerId. Checkout metadata propagates to the resulting Order.
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,
});
}- Successful payment
- Verify the Polar webhook signature, then handle
order.paid. Do not countorder.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 verifiedcustomer_id. - Refund
- Handle
refund.updatedonly whenstatusissucceeded. UseRefund.id,amount, andcurrency, then retrieveorder_idfor 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
Complete a real checkout
Start from a visit with a referrer or UTM campaign and finish the provider's live payment flow.
- 2
Confirm server delivery
Check that the server revenue request returns 202 and retain failures for retry.
- 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.