Skip to main content

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, Polar or 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. Dodo is not on that list because it has no built-in integration. It uses this endpoint, and the Dodo guide 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

Server-to-server only. Takes Authorization: Bearer <YOUR_API_KEY> (details) 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

Response

Always 202 on success, with one of four statuses.

Errors

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: 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:
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. 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.99paymentrefundedtwice,49.99 payment refunded twice, 12.50 and then a further $10.00:
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.50andthenafurther12.50 and then a further 10.00, ending $22.50 down.
Send 10.00 for that second refund and nothing is subtracted. We read it as “10.00hasbeenrefundedintotal",whichislessthanthe10.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.
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 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:
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. 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.

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 or 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; we can remove the duplicate rows.

Examples

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.
Every example below converts minor units to major ones with one of these helpers, because amount takes the major unit:

curl

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 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:
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 lists the full set of events to enable.

Polar

Lemon Squeezy

Paddle

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:
The robust way to capture that value is window.traceten.getVisitorId(), called client-side and sent to whatever creates the order:
If you’d rather not add a client-side round trip, read the cookie directly on your checkout page instead.
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.

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