> ## 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.

# Payment API

> Record a payment from any payment processor and attribute it to the AI source that produced it.

## What this lets you do

Send Traceten a payment from a processor we do not have a built-in integration for (Dodo, Podia, Kajabi, Ghost, your own checkout) and have it attributed to the AI source that referred the customer. One endpoint, no adapter, no webhook to configure on your side.

If you use Stripe, Shopify, [Lemon Squeezy](/integrations/lemonsqueezy), [Polar](/integrations/polar) or [Paddle](/integrations/paddle), connect that integration instead: it provisions the webhook for you and imports your recent history. Do not use both for the same processor, or the same payment is counted twice. See [Avoiding duplicate payments](#avoiding-duplicate-payments).

Dodo is not on that list because it has no built-in integration. It uses this endpoint, and the [Dodo guide](/integrations/dodo) shows how to post to it from Dodo's own dashboard with no server of yours in the loop.

## Before you start

* The Traceten snippet installed on the site where the customer browses. Attribution matches a payment back to a browsing session, so without the snippet there is nothing to match to.
* An API key with the `ingest:write` permission. Create one in **Settings → API keys**.
* A way to identify the customer at payment time: the visitor id, or the email address they paid with. Either works. Without one, the payment is recorded as unattributed revenue.

## `POST /v1/server/payments`

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

Server-to-server only. Takes `Authorization: Bearer <YOUR_API_KEY>` ([details](/api/authentication)) and the `ingest:write` permission. It sends no CORS headers and cannot be called from a browser, which is deliberate: the key is a secret.

### Request body

| Field             | Type    | Required | Description                                                                                                                                                                                                                                                                                                                                                                        |
| ----------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `site_id`         | string  | Yes      | Your site key, the `ttid_...` value the snippet uses.                                                                                                                                                                                                                                                                                                                              |
| `transaction_id`  | string  | Yes      | The processor's own id for this payment. Up to 255 characters. This is the idempotency key. Must not be an email address.                                                                                                                                                                                                                                                          |
| `amount`          | number  | Yes      | The amount in the currency's **major unit**: `49.99` for \$49.99, `5000` for ¥5000. Not cents. Must be zero or positive, and no greater than `1000000000000` (one trillion).                                                                                                                                                                                                       |
| `currency`        | string  | Yes      | Three-letter ISO 4217 code. Case-insensitive on the way in, stored uppercase.                                                                                                                                                                                                                                                                                                      |
| `provider`        | string  | No       | Your label for the processor: `dodo`, `polar`, `kajabi`. Lowercase, 1 to 32 characters. Defaults to `api`.                                                                                                                                                                                                                                                                         |
| `visitor_id`      | string  | No       | The visitor id from the `_traceten_vid` cookie. The strongest match available.                                                                                                                                                                                                                                                                                                     |
| `email`           | string  | No       | The customer's email address. Used only to find their sessions, and never stored. See [What we do with the email](#what-we-do-with-the-email).                                                                                                                                                                                                                                     |
| `customer_id`     | string  | No       | The processor's customer identifier, for your own cross-referencing. An email address or phone number here is replaced with `[REDACTED]`.                                                                                                                                                                                                                                          |
| `renewal`         | boolean | No       | `true` for a subscription renewal. Recorded; see [Subscriptions and renewals](#subscriptions-and-renewals). Defaults to `false`.                                                                                                                                                                                                                                                   |
| `refunded`        | boolean | No       | `true` if this payment has been refunded. Never send a negative `amount`. Defaults to `false`.                                                                                                                                                                                                                                                                                     |
| `refunded_amount` | number  | No       | The **total** refunded against this transaction so far, in the currency's major unit, as a positive number. Cumulative, not this refund's own amount. See [Refunds](#refunds). Must be above zero and no greater than `1000000000000`. Sending it on its own marks the payment refunded, so you do not also need `refunded: true`. Omit it to refund everything still outstanding. |
| `is_free_trial`   | boolean | No       | `true` for a trial signup. Implied by `amount: 0`. Defaults to `false`.                                                                                                                                                                                                                                                                                                            |
| `timestamp`       | string  | No       | ISO 8601 time of the payment. Defaults to when we receive it. May not be more than **24 hours in the future**. Backdating is unrestricted.                                                                                                                                                                                                                                         |

### Response

Always `202` on success, with one of four statuses.

```json theme={null}
{ "status": "recorded", "transaction_id": "pay_9fK2mQ" }
```

| `status`    | What happened                                                                                                                                                                                      |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `recorded`  | The payment was accepted and is being attributed.                                                                                                                                                  |
| `trial`     | Recorded as a free trial. Counted as a conversion, worth zero revenue.                                                                                                                             |
| `refunded`  | Recorded as a refund against the source that earned the original payment. We subtract whatever your running total has not already taken back, which is sometimes nothing. See [Refunds](#refunds). |
| `duplicate` | We already had this `transaction_id`. Nothing new was created.                                                                                                                                     |

### Errors

| Code  | Body                                           | What to do                                                                                                                    |
| ----- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `400` | `{"error":"invalid JSON"}`                     | The body did not parse. Check your `Content-Type` and serialization.                                                          |
| `401` | `{"error":"unauthorized"}`                     | Missing, malformed, revoked, expired, or wrong-site key, or a `site_id` that is not a `ttid_` key. See [Errors](/api/errors). |
| `422` | `{"error":"validation_failed","issues":[...]}` | A field is invalid. `issues` names which one.                                                                                 |
| `429` | `{"error":"rate_limited"}`                     | Back off and retry. Honour `Retry-After` when present.                                                                        |
| `429` | `{"error":"quota_exceeded"}`                   | The site is scheduled for deletion, so ingestion is suspended. Cancel the deletion, then resend.                              |
| `500` | `{"error":"internal_error"}`                   | A misconfiguration on our side. **Retry**, and tell us if it persists.                                                        |
| `503` | `{"error":"unavailable"}`                      | We could not deduplicate the payment, so we refused it rather than risk counting it twice. **Retry.**                         |
| `503` | `{"error":"publish_failed"}`                   | The payment was accepted but we could not queue it, so we released the claim. **Retry.**                                      |

`503` is worth reading twice: retry it. A retry cannot create a second payment, so retrying is both safe and necessary. A `503` after a publish timeout may mean the payment was recorded after all.

#### What a `422` usually means

`issues` names the offending field. The ones worth knowing before you build against this endpoint:

| Field                        | Refused when                                                                                                                              |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `amount`                     | Negative, or greater than `1000000000000`. A refund is the `refunded` flag, never a negative amount.                                      |
| `refunded_amount`            | Zero or negative, or greater than `1000000000000`.                                                                                        |
| `timestamp`                  | More than 24 hours ahead of now. Backdating is fine, and is how you post a refund that happened yesterday.                                |
| `amount` sent in minor units | Not an error on its own, but `4999` where you meant `49.99` is the most common wrong number this endpoint records. See the note on units. |
| `transaction_id`             | Empty, over 255 characters, or containing an email address. Send an opaque processor id.                                                  |
| `provider`                   | Uppercase letters, over 32 characters, or not starting with a letter.                                                                     |
| `currency`                   | Not a three-letter ISO 4217 code.                                                                                                         |

The two ceilings are there because an amount past them cannot be stored, and an unbounded one used to stall ingestion for everyone sharing that queue rather than failing your one request. A far-future `timestamp` is refused for a related reason: this field picks which day the payment lands on, so a payment dated 2099 is invisible in every dashboard window and outside every data-deletion date range.

## Idempotency

Re-posting the same `transaction_id` for the same `provider` and site never creates a second payment. You get `202` with `"status": "duplicate"` instead.

This is durable and has no expiry. A processor that retries a webhook three weeks later, or a nightly reconciliation job that re-sends the last 30 days, cannot inflate your revenue. That is why retrying a `429` or a `5xx` is safe, and why the SDKs do it automatically.

The idempotency key is the combination of your site, the `provider` label, and `transaction_id`. Two processors that happen to issue the same transaction id do not collide, as long as you give them different `provider` labels.

## Refunds

Send the refund as a flag, not as a negative amount:

```json theme={null}
{
  "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
  "transaction_id": "pay_9fK2mQ",
  "amount": 49.99,
  "currency": "USD",
  "refunded": true
}
```

Post the refund with the **same `transaction_id` and `provider` as the original payment**. That is what ties the two together. We answer `"status": "refunded"` and do not create a second payment.

**Attributed revenue is net of refunds.** The refund is subtracted from whichever AI source the original payment was attributed to, in your dashboard and in the [Revenue API](/api/revenue). Four rules govern how:

**1. A refund is dated on the day you post it, not the day of the original payment.** A past day's revenue figure is never rewritten. A day on which you refunded more than you took in reads as a negative number, which is the honest answer rather than a floor at zero.

**2. Conversion counts stay gross.** A refunded payment still counts as a conversion, because the purchase still happened. The money that came back is reported separately as a **refunded** figure beside the net one, so you can see both without either being wrong.

**3. `refunded_amount` is the running total, not this refund's own amount.** Send the total refunded against the transaction **so far**, as a positive number in the major unit. On the first partial refund those two figures are the same. On the second they are not, and that is where it goes wrong if you send the wrong one.

Take a $49.99 payment refunded twice, $12.50 and then a further \$10.00:

```json theme={null}
{
  "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
  "transaction_id": "pay_9fK2mQ",
  "amount": 49.99,
  "currency": "USD",
  "refunded": true,
  "refunded_amount": 12.5
}
```

```json theme={null}
{
  "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
  "transaction_id": "pay_9fK2mQ",
  "amount": 49.99,
  "currency": "USD",
  "refunded": true,
  "refunded_amount": 22.5
}
```

The second body says `22.5`, which is `12.50 + 10.00`. We subtract the difference between the total you send and the total already taken back, so the source loses $12.50 and then a further $10.00, ending \$22.50 down.

<Warning>
  Send `10.00` for that second refund and **nothing is subtracted**. We read it as "$10.00 has been
      refunded in total", which is less than the $12.50 already taken back, so there is nothing left to
  do. You still get `202` with `"status": "refunded"`, because the body was valid and the ledger
  accepted it. Your revenue is then overstated by \$10.00 with no error anywhere to tell you.
</Warning>

The running total is what makes retries safe. This endpoint has no refund id field, so the figure itself is the identity of the refund: re-posting `22.5` after a timeout is a no-op rather than a second \$10.00 subtraction. A per-refund delta would be indistinguishable from a retry of the previous one.

If your processor reports per-refund amounts rather than a running total, keep the total yourself, or read the payment back from the processor and sum its refunds before posting. The [Dodo guide](/integrations/dodo) works through exactly that.

Omit `refunded_amount` and we take back everything still outstanding on that transaction, which is the right call for a full refund. We never subtract more than is outstanding, so an over-large total cannot drive a source below zero on that payment.

**4. Cancelling a subscription is not a refund.** A cancellation stops future charges. The charges already taken were earned, and Traceten leaves them in your totals. Post a refund only when money actually went back to the customer.

Set `timestamp` on the refund if you are posting it later than it happened, so it lands on the right day.

## Free trials

A payment with `amount: 0`, or with `is_free_trial: true`, is recorded as a trial:

```json theme={null}
{
  "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
  "transaction_id": "sub_trial_8812",
  "amount": 0,
  "currency": "USD",
  "provider": "polar",
  "email": "ada@example.com"
}
```

Trials are attributed to an AI source like any other payment, so you can answer "how many trial signups did ChatGPT send us". They carry **zero** revenue, so they never move a revenue total.

Because a trial is stored as a conversion worth zero, it **does** appear in conversion counts alongside paid orders, and it lowers the average order value you would compute from those two numbers. If you need paid-only figures today, compare against the revenue totals rather than the conversion counts.

## Subscriptions and renewals

`renewal: true` records that a payment is a recurring charge rather than a first one. It is accepted and stored on the payment.

It does **not** currently feed [lifetime value](/api/revenue). That report groups by subscription, and the Payment API has no subscription identifier to group on: `customer_id` identifies the customer, not the subscription, and putting it in a field labelled `subscription_id` would make "how many subscriptions" answer with a customer count instead. Renewals therefore appear in your revenue totals and your per-source breakdowns like any other payment, and do not appear in the LTV report.

## Matching a payment to a session

We try, in this order:

1. **`visitor_id`.** An exact match, and the best privacy posture: no personal data is involved at all. The robust way to get it is `window.traceten.getVisitorId()`, called client-side and passed through your processor's metadata. It always resolves the current visitor cookie, so it survives you turning cross-subdomain cookies on or off later. If you read the cookie by name instead, its shape depends on the site's cookie scope: cross-subdomain cookies are **off by default**, giving plain `_traceten_vid`; once you confirm a value under **Sites → Settings → Cookies**, it becomes `_traceten_vid_` followed by eight characters of your site key. Your install page and Settings show the exact current name. Read that name exactly rather than by prefix: two Traceten sites under one registered domain each set their own cookie, and a prefix match picks whichever the browser lists first.
2. **`email`.** We hash the address and look for sessions belonging to that person, including sessions from before they logged in.
3. **Neither.** The payment is recorded as unattributed revenue. It still counts toward your totals; we just cannot say which source earned it.

We look back 30 days from the payment timestamp.

### What we do with the email

The address is used to compute a lookup hash and is then discarded. It is never written to storage, never appears in logs, and never reaches the analytics database. This is the same handling as the customer email on a Stripe payment event. See [Data we collect](/privacy/data-collected).

## Avoiding duplicate payments

If you run **both** this endpoint and a native integration for the same processor, every payment is recorded twice and your revenue is overstated.

The two paths do not know about each other. They deduplicate on different keys: the native Stripe integration deduplicates on Stripe's event id, and this endpoint deduplicates on the `transaction_id` you send. Nothing can reconcile them.

Pick one per processor:

* **Stripe or Shopify** → use the [Stripe](/integrations/stripe) or [Shopify](/integrations/shopify) integration. Do not also send those payments here.
* **Anything else** → use this endpoint.

If you have already double-counted, disconnect one of the two paths and contact [support@traceten.com](mailto:support@traceten.com); we can remove the duplicate rows.

## Examples

<Warning>
  The minor-unit divisor is **not always 100**. JPY, KRW and ISK have no minor unit at all, so a
  ¥5000 sale arrives from your processor as `5000`, and posting `5000 / 100` records it as ¥50, a
  hundredth of the sale, with no error anywhere to tell you. If you sell in any of those three,
  branch on the currency.
</Warning>

Every example below converts minor units to major ones with one of these helpers, because `amount` takes the major unit:

```ts theme={null}
const ZERO_DECIMAL = new Set(["ISK", "JPY", "KRW"]);
const toMajor = (minor: number, currency: string): number =>
  ZERO_DECIMAL.has(currency.toUpperCase()) ? minor : minor / 100;
```

```python theme={null}
ZERO_DECIMAL = {"ISK", "JPY", "KRW"}

def to_major(minor: int, currency: str) -> float:
    return minor if currency.upper() in ZERO_DECIMAL else minor / 100
```

### curl

```bash theme={null}
curl -X POST https://ingest.traceten.com/v1/server/payments \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "transaction_id": "pay_9fK2mQ",
    "amount": 49.99,
    "currency": "USD",
    "provider": "dodo",
    "email": "ada@example.com"
  }'
```

```json theme={null}
{ "status": "recorded", "transaction_id": "pay_9fK2mQ" }
```

### Dodo Payments

Dodo can also post directly to this endpoint from its own dashboard, with no server of yours in the loop at all. See the [Dodo Payments integration guide](/integrations/dodo) for that method. To forward from your own server instead, capture `traceten_visitor_id` in the checkout session's `metadata` (see the guide for how), then forward Dodo's webhook on:

```ts theme={null}
import { Client } from "@traceten/sdk-node";
import DodoPayments from "dodopayments";

const traceten = new Client({
  siteId: "ttid_7Rb4TrC1dTbnD8w3s1TS12",
  host: "https://ingest.traceten.com",
  apiKey: process.env.TRACETEN_API_KEY!,
});

const dodopayments = new DodoPayments({ bearerToken: process.env.DODO_API_KEY! });

// Dodo reports minor units. Most currencies have two decimal places, but JPY,
// KRW and ISK have none: 5000 there means 5000 yen, not 50.
const ZERO_DECIMAL = new Set(["ISK", "JPY", "KRW"]);
const toMajor = (minor: number, currency: string): number =>
  ZERO_DECIMAL.has(currency.toUpperCase()) ? minor : minor / 100;

app.post("/webhooks/dodo", async (req, res) => {
  if (req.body.type === "payment.succeeded") {
    const payment = req.body.data;
    await traceten.payment({
      transactionId: payment.payment_id,
      amount: toMajor(payment.total_amount, payment.currency),
      currency: payment.currency,
      provider: "dodo",
      visitorId: payment.metadata?.traceten_visitor_id,
      email: payment.customer.email,
      customerId: payment.customer.customer_id,
      // Dodo always settles to USD, GBP or EUR (all priced by Traceten),
      // so this is a safe fallback for the rare native currency
      // Traceten can't price on its own. Omit either field and the SDK sends
      // neither; only pass both when Dodo's payload includes them.
      ...(payment.settlement_amount !== undefined && payment.settlement_currency
        ? {
            settlementAmount: toMajor(payment.settlement_amount, payment.settlement_currency),
            settlementCurrency: payment.settlement_currency,
          }
        : {}),
    });
  }

  if (req.body.type === "refund.succeeded") {
    const refund = req.body.data;

    // `refunded_amount` is the TOTAL refunded against the payment so far, and
    // the webhook only states THIS refund's amount. Read the payment back and
    // sum every refund on it.
    const payment = await dodopayments.payments.retrieve(refund.payment_id);
    const currency = payment.currency;
    const refundedMinor = (payment.refunds ?? []).reduce((sum, r) => sum + r.amount, 0);
    const isFullRefund = refundedMinor >= payment.total_amount;

    await fetch("https://ingest.traceten.com/v1/server/payments", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.TRACETEN_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        site_id: "ttid_7Rb4TrC1dTbnD8w3s1TS12",
        // The ORIGINAL payment's id, so the refund lands against it.
        transaction_id: refund.payment_id,
        amount: toMajor(payment.total_amount, currency),
        currency,
        provider: "dodo",
        refunded: true,
        // Left off on a full refund: that takes back everything outstanding,
        // which lands on exactly zero even if the summed refunds are a cent short.
        ...(isFullRefund ? {} : { refunded_amount: toMajor(refundedMinor, currency) }),
      }),
    });
  }

  res.sendStatus(200);
});
```

Two things this example is careful about. `refunded_amount` is the **running total** refunded against the payment, not the amount of the refund that just fired, so the handler reads the payment back from Dodo and sums its `refunds` array. And the minor-unit divisor is not always 100: a ¥5000 charge posted as `5000 / 100` records as ¥50.

Pass `visitorId` when `metadata.traceten_visitor_id` is present; without it, the payment matches by email or lands unattributed.

Subscribe the Dodo endpoint to `refund.succeeded` as well as `payment.succeeded`, or refunds can never reach Traceten and your revenue only ever goes up. The [Dodo guide](/integrations/dodo) lists the full set of events to enable.

### Polar

```python theme={null}
import os
import traceten

client = traceten.Client(
    "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "https://ingest.traceten.com",
    api_key=os.environ["TRACETEN_API_KEY"],
)

@app.post("/webhooks/polar")
def polar_webhook(event: dict):
    order = event["data"]
    client.payment(
        transaction_id=order["id"],
        # `to_major` from the note above: Polar sends minor units.
        amount=to_major(order["amount"], order["currency"]),
        currency=order["currency"],
        provider="polar",
        email=order["customer"]["email"],
        renewal=order["billing_reason"] == "subscription_cycle",
    )
    return {"ok": True}
```

### Lemon Squeezy

```ts theme={null}
app.post("/webhooks/lemonsqueezy", async (req, res) => {
  const order = req.body.data.attributes;
  await traceten.payment({
    transactionId: req.body.data.id,
    // Lemon Squeezy sends minor units in `total`; `total_formatted` is a
    // display string. `toMajor` is from the note above.
    amount: toMajor(order.total, order.currency),
    currency: order.currency,
    provider: "lemonsqueezy",
    email: order.user_email,
    refunded: order.refunded,
  });
  res.sendStatus(200);
});
```

### Paddle

```ts theme={null}
app.post("/webhooks/paddle", async (req, res) => {
  const txn = req.body.data;
  await traceten.payment({
    transactionId: txn.id,
    // Paddle sends minor units as a STRING. `toMajor` is from the note above.
    amount: toMajor(Number(txn.details.totals.grand_total), txn.currency_code),
    currency: txn.currency_code,
    provider: "paddle",
    email: txn.customer?.email,
    customerId: txn.customer_id,
  });
  res.sendStatus(200);
});
```

### Kajabi, Podia, Ghost, or your own checkout

Anything that can make an HTTP request works. Pass the visitor id if you captured it at checkout, which gives a stronger match than the email:

```ts theme={null}
await traceten.payment({
  transactionId: order.id,
  amount: order.total,
  currency: "USD",
  provider: "kajabi",
  visitorId: visitorIdFromClient,
});
```

The robust way to capture that value is `window.traceten.getVisitorId()`, called client-side and sent to whatever creates the order:

```js 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
```

If you'd rather not add a client-side round trip, read the cookie directly on your checkout page instead.

<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>

## Verify it worked

Post a payment, then open **Revenue** in the dashboard and select today. The payment appears within a minute or two, either against an AI source or under "Unattributed" if we could not match it to a session.

If it does not appear:

* **Got `duplicate` back?** You have sent that `transaction_id` before. Use a new one to test.
* **Got `422`?** Read `issues`, which names the field. The most common causes are `amount` sent in cents (send `49.99`, not `4999`), a `provider` with uppercase letters, and a `timestamp` more than 24 hours in the future, which usually means a clock or timezone bug rather than a genuine future date. See [what a `422` usually means](#what-a-422-usually-means).
* **Attributed to "Unattributed"?** We could not match it to a session. Check that the snippet is installed, that the visitor browsed within 30 days, and that the email you send matches the one they used on your site.
* **Posted a refund and the revenue did not move?** `refunded_amount` is the **total** refunded against that transaction so far, not the amount of the refund you just processed. A second partial refund posted as its own amount is usually smaller than the total already taken back, so nothing is left to subtract and you still get `"status": "refunded"`. See [Refunds](#refunds).
* **Got `503`?** Retry. Nothing was recorded.
* **Got `429` with `quota_exceeded`?** The site is scheduled for deletion. Restore it under **Settings → Sites**, then resend.

## Next

* [Revenue API](/api/revenue) to read attributed revenue back out
* [Currencies](/integrations/currencies) for how non-USD amounts are converted
* [Node SDK](/sdks/node), [Python SDK](/sdks/python), [Go SDK](/sdks/go)
