> ## Documentation Index
> Fetch the complete documentation index at: https://docs.traceten.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Dodo Payments

> Attribute Dodo Payments revenue to AI-referred sessions.

## What this lets you do

Send Traceten your Dodo Payments revenue and see which AI sources drive paying customers, not just pageviews. Dodo has no built-in Traceten integration, so there is no key to paste and no connect flow: Dodo sends the payment to Traceten directly, either from its own dashboard or from your server.

## Before you start

* A Traceten account with at least one site tracked.
* A Dodo Payments account.
* The Traceten snippet installed and verified on the site your checkout runs from.
* A Traceten API key with the `ingest:write` permission. Create one in **Settings → API keys**. It is shown once.

## Two ways to forward payments

Pick one. Both call the same [Payment API](/api/payments) endpoint and attribute the same way; they differ only in where the payload gets reshaped.

|                             | Runs on                   | Needs a server |
| --------------------------- | ------------------------- | -------------- |
| **A. Directly from Dodo**   | Dodo's own infrastructure | No             |
| **B. From your own server** | Your webhook handler      | Yes            |

### Method A: send directly from Dodo (no server needed)

Dodo's webhook endpoints support a transformation function that runs on Dodo's own servers before dispatch. You can point it straight at Traceten's Payment API, so no code runs on your side at all.

<Steps>
  <Step title="Create a webhook endpoint in Dodo">
    Open **Dodo Dashboard → Webhooks → + Add Endpoint**. Set the endpoint URL to Traceten's Payment API:

    ```
    https://ingest.traceten.com/v1/server/payments
    ```

    In the integrations dropdown, pick **Custom** rather than one of the named presets. Make sure the endpoint is **Enabled**. A disabled endpoint stops receiving events entirely, which is not something you'll notice from the transform or headers being correct.

    Subscribe it to these eight events, then create it:

    | Event                       | Why                                                                  |
    | --------------------------- | -------------------------------------------------------------------- |
    | `payment.succeeded`         | The payment itself. This is the revenue.                             |
    | `refund.succeeded`          | The refund. Without it, refunded money stays in your totals forever. |
    | `subscription.active`       | A subscription started.                                              |
    | `subscription.renewed`      | A subscription renewed.                                              |
    | `subscription.cancelled`    | A subscription was cancelled (two Ls, Dodo's spelling).              |
    | `subscription.expired`      | A subscription ended at the end of its term.                         |
    | `subscription.on_hold`      | Billing paused, usually after a failed payment.                      |
    | `subscription.plan_changed` | The customer moved to a different plan.                              |

    The transform below posts two of these to Traceten: `payment.succeeded` and `refund.succeeded`. Those are the two that move money. Subscribe to the rest so the whole subscription lifecycle lands on one endpoint, which is what you need if you handle it in your own server (method B). They change no revenue figure on their own: a cancellation stops future charges, it does not take back the ones already paid.

    <Warning>
      Subscribing to `payment.succeeded` alone means a refund can never reach Traceten, so your
      revenue only ever goes up. If you already have an endpoint set up that way, add
      `refund.succeeded` to it.
    </Warning>
  </Step>

  <Step title="Open the endpoint and go to Advanced">
    Click into the endpoint you just created and open its **Advanced** tab. Both the transformation
    code and the custom headers live here.
  </Step>

  <Step title="Set the transformation code">
    Replace the transformation code with:

    ```js theme={null}
    const SITE_ID = "ttid_7Rb4TrC1dTbnD8w3s1TS12"; // REPLACE with your own site key
    const TRACETEN_URL = "https://ingest.traceten.com/v1/server/payments";

    // Dodo reports minor units. Most currencies have two decimal places, but
    // JPY, KRW and ISK have none: 5000 there is 5000 yen, not 50.
    const ZERO_DECIMAL = { ISK: true, JPY: true, KRW: true };

    function toMajor(minor, currency) {
      return ZERO_DECIMAL[String(currency).toUpperCase()] ? minor : minor / 100;
    }

    function handler(webhook) {
      if (webhook.eventType === "payment.succeeded") {
        const payment = webhook.payload.data;

        if (!payment.metadata || !payment.metadata.traceten_visitor_id) {
          return null; // no visitor id, so skip dispatch entirely
        }

        webhook.url = TRACETEN_URL;
        webhook.payload = {
          site_id: SITE_ID,
          transaction_id: payment.payment_id,
          amount: toMajor(payment.total_amount, payment.currency),
          currency: payment.currency,
          provider: "dodo",
          visitor_id: payment.metadata.traceten_visitor_id,
          email: payment.customer && payment.customer.email,
          customer_id: payment.customer && payment.customer.customer_id,
          renewal: Boolean(payment.subscription_id),
        };
        // Dodo's own settlement-currency figure: always USD, GBP or EUR. A
        // fallback Traceten uses only if `currency` above isn't one it can
        // price; harmless to send on every payment. Send both or neither.
        if (payment.settlement_amount != null && payment.settlement_currency) {
          webhook.payload.settlement_amount = toMajor(
            payment.settlement_amount,
            payment.settlement_currency,
          );
          webhook.payload.settlement_currency = payment.settlement_currency;
        }
        return webhook;
      }

      if (webhook.eventType === "refund.succeeded") {
        const refund = webhook.payload.data;

        webhook.url = TRACETEN_URL;
        const payload = {
          site_id: SITE_ID,
          // The ORIGINAL payment's id, not the refund's. That is what ties the
          // refund to the payment Traceten already recorded.
          transaction_id: refund.payment_id,
          amount: toMajor(refund.amount, refund.currency),
          currency: refund.currency,
          provider: "dodo",
          refunded: true,
        };
        if (refund.is_partial) {
          // `refunded_amount` is the TOTAL refunded against this payment so
          // far. The transform sees one event at a time and cannot add up the
          // ones before it, so this is exact only on a payment's FIRST partial
          // refund. See the warning below.
          payload.refunded_amount = toMajor(refund.amount, refund.currency);
        }
        webhook.payload = payload;
        return webhook;
      }

      return null; // subscription events: nothing to post
    }
    ```

    Three things that are easy to get wrong here:

    * **The refund's `transaction_id` is the original `payment_id`.** Sending the refund's own id would create a second, unrelated payment instead of reducing the first one.
    * **The divisor is not always 100.** JPY, KRW and ISK have no minor unit, so a ¥5000 charge sent as `5000 / 100` records as ¥50 and no error is raised. `toMajor` is what keeps those three right.
    * **Never send a negative `amount`.** The endpoint rejects it. A refund is a positive number with `refunded: true`.

    <Warning>
      **A payment refunded in two or more parts needs method B.** `refunded_amount` is the running
      total refunded against a payment, and a transform runs on one event with no memory of the
      ones before it, so it can only ever report the refund in front of it. The first partial
      refund of a payment is therefore exact, and a second one subtracts too little or nothing at
      all, with a `202` either way. Full refunds are unaffected: they send no `refunded_amount`, and
      Traceten takes back everything still outstanding. If you issue more than one partial refund
      against the same payment, forward refunds from your own server instead, where you can read the
      payment back and sum its `refunds` array.
    </Warning>

    <Warning>
      Replace `site_id` with your own site key before saving. Dodo's transform has no access to
      environment variables, so it has to be written in directly.
    </Warning>
  </Step>

  <Step title="Add the Authorization header">
    In the same **Advanced** tab, under **Custom headers**, add one header:

    | Name            | Value                            |
    | --------------- | -------------------------------- |
    | `Authorization` | `Bearer <YOUR_TRACETEN_API_KEY>` |

    The header name has to be exactly `Authorization`. A header named after your key (for example `TRACETEN_API_KEY`) is a header Traceten never looks at, and the request will 401.
  </Step>

  <Step title="Save and test">
    Save the endpoint. Dodo's own **Testing** tab sends a sample payload through the transform if you want to check it before going live.
  </Step>
</Steps>

Dodo now posts successful payments and refunds straight to Traceten. Nothing runs on your infrastructure, and there is nothing to keep running.

### Method B: forward from your own server

If you'd rather keep the transform in your own codebase (versioned, tested, deployed with the rest of your backend), point Dodo's webhook at your own server instead and forward from there. See the [Payment API's Dodo example](/api/payments#dodo-payments) for a complete handler.

This is also the method to use if you issue **more than one partial refund** against the same payment. Traceten's `refunded_amount` is the running total refunded against a payment, and a `refund.succeeded` event states only the refund that just happened. A handler on your own server can call Dodo back for the payment and sum its `refunds` array, which is the running total; Dodo's transform cannot, because it has no network access and no memory of earlier events.

Dodo follows the [Standard Webhooks](https://www.standardwebhooks.com) specification, so your handler gets three headers:

| Header              | What it is for                                               |
| ------------------- | ------------------------------------------------------------ |
| `webhook-id`        | The delivery's unique id. Use it as your idempotency key.    |
| `webhook-timestamp` | Unix seconds. Reject anything too old to stop a replay.      |
| `webhook-signature` | The signature over the raw body. Verify it before you parse. |

Verify the signature against the **raw** request body, before any JSON parsing. Re-serialising the body changes the bytes and the signature will not match.

Every event arrives in the same envelope:

```json theme={null}
{
  "business_id": "bus_7Kd2",
  "type": "refund.succeeded",
  "timestamp": "2026-09-12T10:14:03Z",
  "data": {
    "payload_type": "Refund",
    "refund_id": "ref_3Qa8",
    "payment_id": "pay_9fK2mQ",
    "amount": 1200,
    "currency": "USD",
    "reason": "requested_by_customer",
    "is_partial": true
  }
}
```

`data.payload_type` tells you which object you have: `Payment`, `Subscription`, `Refund` or `Dispute`. Branch on it, or on the top-level `type`, rather than guessing from which fields are present.

## Passing the visitor ID through checkout

Both methods depend on the same thing: `traceten_visitor_id` in the payment's `metadata`, set when you create the Dodo checkout session. Without it, the payment either records as unattributed (method B) or is never sent at all (method A's transform skips dispatch on purpose, to avoid a pointless API call).

<Tip>
  In the dashboard, open **Settings → Integrations**, pick **Dodo Payments** and click **Set up with
  AI**. It copies a prompt for your coding assistant that wires the visitor ID into your checkout
  and forwards payments to the Payment API (method B). The same card shows this site's **Visitor
  cookie name**.
</Tip>

The robust way to get the value is `window.traceten.getVisitorId()`, called client-side and forwarded to whatever creates the checkout session:

```typescript theme={null}
// Client-side: read the visitor id and send it to your own backend
const visitorId = window.traceten.getVisitorId(); // null before the first pageview resolves
```

```typescript theme={null}
// Server-side: create a Dodo checkout session, using the value your client sent
const session = await dodopayments.checkoutSessions.create({
  product_cart: [{ product_id: "pdt_...", quantity: 1 }],
  metadata: {
    traceten_visitor_id: visitorIdFromClient ?? "",
  },
});
```

If you'd rather not add a client-side round trip, read the cookie directly server-side instead:

```typescript theme={null}
// Server-side: read whatever cookie name Sites → Settings → Cookies shows for this site
const visitorId = req.cookies["_traceten_vid"] ?? "";
```

<Warning>
  Do not hardcode this cookie name. It is per-site and only correct on your configured domain (not
  on localhost). Find the real one under **Sites → Settings → Cookies → Visitor cookie
  name**.
</Warning>

Dodo echoes `metadata` back on the `payment.succeeded` webhook as a top-level object on the Payment payload, so whichever method reads it, it reaches Traceten unchanged.

Dodo also appends its own query parameters to your `return_url` after checkout: `payment_id` on a one-time purchase, `subscription_id` on a subscription, `status` on both, plus `license_key` and `email`. You do not add template variables for these. Traceten uses `payment_id` on its own, which is what the next section is about.

## Checkout links: attribute without passing metadata

If you sell through a Dodo checkout link that never touches your server, there is nowhere to set `metadata.traceten_visitor_id`. There is nothing to configure for this: set your `return_url` to a page on your site that has the Traceten snippet installed, and Dodo appends `payment_id` to it by itself. The snippet reads that id and reports it, and Traceten matches it to the browser that completed the purchase.

```
https://yourdomain.com/thanks
```

Dodo turns that into `https://yourdomain.com/thanks?payment_id=pay_ts2ySpzg07phGeBZqePbH&status=succeeded` on redirect.

<Warning>
  **Method A's transform skips these payments by default.** The transform above returns `null` when
  `metadata.traceten_visitor_id` is missing, so a checkout-link payment is never sent to Traceten at
  all, and a redirect signal has no payment to attach to. To use this path with method A, delete
  these two lines from the transform:

  ```js theme={null}
  if (!payment.metadata || !payment.metadata.traceten_visitor_id) {
    return null; // no visitor id, so skip dispatch entirely
  }
  ```

  and change the `visitor_id` line to send an empty value when there is no metadata:

  ```js theme={null}
  visitor_id: (payment.metadata && payment.metadata.traceten_visitor_id) || null,
  ```

  Method B is unaffected: if your own handler forwards every `payment.succeeded`, the payment is
  already reaching Traceten.
</Warning>

More things worth knowing:

* **Your server API key must be active.** Traceten keeps a return-page report only for a site whose account has an active server API key with the `ingest:write` scope, the key your payments already reach Traceten with. With no such key, or once it is revoked, reports are discarded rather than held. A report can also be discarded for up to a minute after you create the key.
* **The order of events does not matter.** Dodo's webhook usually reaches Traceten before the buyer's browser finishes redirecting. When that happens the payment is recorded first and re-attributed the moment the browser reports in, on the original payment's own date.
* **The return page must load within 30 minutes of payment.** Traceten pairs a report with a payment only when the two are no more than 30 minutes apart. Dodo's redirect happens straight after payment, so this only matters for a return page opened again much later.
* **Subscriptions are not covered.** Dodo appends `subscription_id` rather than `payment_id` on a subscription checkout, and a subscription id is not what the payment is recorded under. Pass `metadata.traceten_visitor_id` for those.

This path matches a browser, not a person. A buyer who completes checkout on their phone and opens the confirmation link on a laptop is not matched, and neither is a confirmation URL forwarded to somebody else. Checkout metadata, where you can use it, is exact.

## How attribution works

Traceten tries to tie the payment back to the originating session, in priority order:

1. **`metadata.traceten_visitor_id`:** matches the payment to the originating session exactly.
2. **Return URL redirect:** Dodo appends `payment_id` to your `return_url`, and the Traceten snippet on that page reports it. Traceten matches it to the browser that completed the purchase. See [Checkout links](#checkout-links-attribute-without-passing-metadata) above.
3. **Email:** if the buyer's session called `traceten.identify({ email })`, Traceten matches the payment's customer email (hashed, never stored) to that visitor.
4. **No match:** the payment is still recorded so revenue totals stay complete, but it shows as **unattributed** (no AI source).

There is no automatic history import for Dodo. Because nothing here is a Traceten-managed webhook, there is no endpoint whose creation date bounds what can be backfilled. Only payments you actively forward (by either method) are recorded, starting from whenever you set it up.

## Refunds and net revenue

Attributed revenue in Traceten is **net of refunds**. When a `refund.succeeded` event reaches Traceten, it writes a matching entry that subtracts from whichever AI source earned the original payment, so the source's revenue goes down by the amount refunded.

Four things follow from that, and they apply to every revenue source in Traceten, not only Dodo:

* **A refund is dated on the day it happened**, not on the day of the original payment. Yesterday's revenue figure never changes after the fact. A day on which you refunded more than you took in reads as a negative number, which is correct.
* **Conversion counts stay gross.** A refunded payment still counts as a conversion, because it still happened. The money is reported separately as a **refunded** figure beside the net one.
* **`refunded_amount` is the running total, not one refund's amount.** Send the total refunded against the payment so far. A payment refunded $12.50 and then a further $10.00 reports `22.5` on the second post, and Traceten subtracts the $10.00 difference. Post `10.00` instead and nothing is subtracted, because $10.00 is less than the \$12.50 already taken back. Dodo's transform can only report the refund in front of it, which is why a payment refunded in parts more than once belongs in method B.
* **A cancelled subscription is not a refund.** `subscription.cancelled`, `subscription.expired` and `subscription.on_hold` stop future charges. The charges already paid were earned, so they stay in your totals.

If you never subscribe the endpoint to `refund.succeeded`, none of this happens: Traceten never hears about the refund, and the revenue stays in your totals permanently.

## Verify it worked

1. Complete a real payment through your checkout.
2. Check the endpoint you configured (Dodo's dashboard shows delivery attempts and responses for method A; your own logs for method B). A `202` response means Traceten accepted it.
3. Open **Revenue** in the Traceten dashboard and select today. The payment appears within a minute or two, either against an AI source or under "Unattributed."

## Troubleshooting

**Got a `401` from Traceten.** Check the header name is exactly `Authorization`, not the name of your API key, and that the value is `Bearer ` followed by the key with no extra characters.

**Got a `422` from Traceten.** The payload shape is wrong. Compare your transform's output against the [Payment API's request body](/api/payments#request-body). Dodo's own field names (`payment_id`, `total_amount`) do not match Traceten's (`transaction_id`, `amount`), so sending Dodo's raw payload through unmodified always fails here. A negative `amount` is a `422` too: send a refund as a positive amount with `refunded: true`.

**Nothing arrives at Dodo's endpoint at all.** For method A, confirm `webhook.eventType === "payment.succeeded"` matches what your account actually sends, and that the endpoint is enabled and subscribed to that event.

**Revenue only ever goes up.** The endpoint is not subscribed to `refund.succeeded`, so refunds never reach Traceten. Add it under the endpoint's events and refunds from that point on will subtract.

**A refund subtracted more than you refunded.** The transform sent `refunded: true` without `refunded_amount` on a partial refund, which takes back everything outstanding on the transaction. Check the `refund.is_partial` branch.

**A second partial refund on the same payment subtracted nothing.** Expected with method A. `refunded_amount` is the running total, and the transform reported only the latest refund, which is smaller than what has already been taken back. Move refund forwarding to method B, where you can sum the payment's `refunds` array.

**A JPY, KRW or ISK sale landed at a hundredth of its value.** The divisor was 100 for a currency with no minor unit. Check `toMajor` is in the transform and is applied to both `payment.total_amount` and `refund.amount`.

**Everything shows as unattributed.** `metadata.traceten_visitor_id` is not being set at checkout, or it is empty. Confirm the value your checkout-creation code sends is not an empty string.

## Next

* [Payment API reference](/api/payments)
* [How attribution works](/getting-started/how-it-works)
* [Currencies](/integrations/currencies)
