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:writepermission. 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
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
Always202 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 sametransaction_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: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 12.50 and then a further $10.00:
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 10.00, ending $22.50 down.
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 withamount: 0, or with is_free_trial: true, is recorded as a trial:
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:visitor_id. An exact match, and the best privacy posture: no personal data is involved at all. The robust way to get it iswindow.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.email. We hash the address and look for sessions belonging to that person, including sessions from before they logged in.- Neither. The payment is recorded as unattributed revenue. It still counts toward your totals; we just cannot say which source earned it.
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 thetransaction_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.
Examples
Every example below converts minor units to major ones with one of these helpers, becauseamount 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, capturetraceten_visitor_id in the checkout session’s metadata (see the guide for how), then forward Dodo’s webhook on:
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:window.traceten.getVisitorId(), called client-side and sent to whatever creates the order:
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
duplicateback? You have sent thattransaction_idbefore. Use a new one to test. - Got
422? Readissues, which names the field. The most common causes areamountsent in cents (send49.99, not4999), aproviderwith uppercase letters, and atimestampmore than 24 hours in the future, which usually means a clock or timezone bug rather than a genuine future date. See what a422usually 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_amountis 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
429withquota_exceeded? The site is scheduled for deletion. Restore it under Settings → Sites, then resend.
Next
- Revenue API to read attributed revenue back out
- Currencies for how non-USD amounts are converted
- Node SDK, Python SDK, Go SDK

