Docs·Tracking
/1.0/revenueRecord a payment
Records a payment from your server against one audience. Payments are what fill the Revenue numbers, the revenue on goals, and the revenue each channel and campaign earned. Use this route for payments that do not come from a connected payment provider: another provider, an invoice, a deal closed by hand.
If Stripe, Paddle, Polar or Lemon Squeezy is connected in Settings → Revenue, its payments
already arrive on their own, so do not post them here as well. Revenue covers
connecting a provider, and the transactionId to use if you ever need to send one of its payments
yourself.
Unlike the tracking routes, this one is built for servers. It takes a secret key, it does not filter by User-Agent or by cloud address, and it answers with what it did.
The request
POST https://api.spreeflo.com/1.0/revenue, with a JSON body and these headers:
| Header | Value |
|---|---|
Authorization | Bearer YOUR_ACCOUNT_KEY, an account key from Account → API keys. Audience keys do not work here. |
Content-Type | application/json |
X-Team-Id | Optional. The workspace the call acts on. See below. |
The key's owner needs the Editor or Admin role in the workspace that owns the audience. A
Viewer's key is refused with 403.
Which workspace the call acts on
The call acts on one workspace: the one named in X-Team-Id, or, without the header, the workspace
your key was tied to when you created it, which was your default workspace at the time. The audience
in audienceId has to belong to that workspace, or the call answers 410 with
Audience not found.
If you belong to more than one workspace, send X-Team-Id with the id of the audience's workspace.
It is the teamId next to the audience in the audience list, described in
Your audience id.
Fields
| Field | Type | Required | What it is |
|---|---|---|---|
audienceId | string | Yes | The id of the audience the payment belongs to, the value other endpoints call contactListId. See Your audience id. |
eventName | string | Yes | The name the payment is recorded under, such as purchase or plan_purchased. It shows on the Events page like any event, and a goal on it carries the payment's revenue. |
revenue | object | Yes | The amount and currency: { "amount": 49.9, "currency": "USD" }. |
revenue.amount | number | Yes | The amount, zero or more. See Amounts and currencies. |
revenue.currency | string | Yes | A three-letter currency code, such as USD or EUR. |
transactionId | string | Yes | Your id for this payment. Sending it again records nothing. See Duplicates. |
visitorId | string | No | The id of the buyer's browser, which links the payment to their visits. See Linking a payment to a visit. |
sessionId | string | No | The visit the payment happened in, from Spreelytics.getSessionId(). Stored with the payment. |
email | string | No | The buyer's email address, used to find them when there is no matching visitorId. |
name | string | No | The buyer's name, stored with the payment. |
customerId | string | No | Your id for the buyer, stored with the payment. |
renewal | boolean | No | true for a recurring charge rather than a first purchase. Stored with the payment. |
isFreeTrial | boolean | No | true to mark the payment as part of a free trial. Stored with the payment. |
refunded | boolean | No | true makes the call a refund of transactionId. See Refunds. |
refundId | string | No | With refunded, your id for this one refund. |
items | array | No | What was bought, shown under Revenue → Products. See Products. |
timestamp | string | No | When the payment happened, as an ISO 8601 date and time, such as 2026-10-01T09:40:00.000Z. Defaults to the time of the call. |
Any other field makes the request fail with 400.
Amounts and currencies
revenue.amount is in the currency's normal unit: 49.9 for $49.90, not 4990. Zero is allowed.
A negative amount is refused with 400, because a refund is sent with refunded: true, never as a
negative payment.
Send the amount before tax. Payments from a connected Stripe, Paddle, Polar or Lemon Squeezy account are recorded without their tax, so revenue and MRR show what you earn; an amount with tax in it would count the tax as revenue.
revenue.currency can be any three letters, in any case, and is stored in capitals. Each payment is
converted once into the audience's reporting currency, set in Settings → Revenue, at the
exchange rate for the day it happened, and the original amount and currency are kept beside it. A
code with no exchange rate is accepted, but the payment is stored without a converted amount, which
keeps it out of the revenue numbers.
timestamp can be in the past, so a payment that reaches your server late can still be recorded at
the moment it happened. A time more than 5 minutes in the future is refused with 400.
Products
items lists what the payment bought, up to 100 lines, so the payment shows under Revenue → Products:
"items": [
{ "name": "Pro plan", "amount": 39.9 },
{ "name": "Extra seat", "quantity": 2, "amount": 10 }
]
Each line has a name (1 to 200 characters), an amount that is the line's total in revenue.currency,
and a quantity, a whole number that is 1 when left out. The lines don't have to add up to
revenue.amount: tax and discounts can sit between them. Each product gets a share of the payment in
proportion to its line's total. A refund's items are ignored. A line that breaks these rules fails the
request with 400, and detail names the line, for example items[0].amount must be a finite number >= 0.
Duplicates
Every payment is keyed on its transactionId within the audience. A second call with the same id
records nothing and answers skipped (duplicate), so you can retry a call that timed out without
counting the payment twice. If a call fails part-way with an error, its transactionId is released,
and the retry records the payment.
The check only catches the same id. The same sale sent again under a different id is counted twice.
Linking a payment to a visit
The visitor id is what ties a payment to the visits that led to it. Read it in the browser with
Spreelytics.getVisitorId() when the checkout starts, carry it to your server with the order (in the
order's metadata, for example), and send it here as visitorId.
| You send | What happens |
|---|---|
A visitorId Spreelytics knows in this audience | The payment is tied to that visitor, and to the person they were identified as. It can be credited to the channels of the visitor's earlier visits. The answer is attributed. |
No match by visitor, but an email that belongs to an identified person in this audience | The payment is tied to that person and counts on their profile, but without a visitor it cannot be credited to a channel. The answer is attributed. email never creates a person. |
| Neither | The payment is recorded and counts in your totals, but belongs to no one. The answer is recorded. |
A payment that cannot be credited to a visit counts under Direct in the channel and source reports. How credit is split between visits, Last-touch or Linear, and how far back it looks are set in Settings → Revenue: see Revenue.
Refunds
A refund is a call with refunded: true, the transactionId of the original payment, and an amount.
Send it with the same eventName and currency as the payment, so goals on that event net it out.
{
"audienceId": "8f14e45f-ceea-4e7a-9b2c-1d5a3f6b7c8d",
"eventName": "purchase",
"revenue": { "amount": 10, "currency": "USD" },
"transactionId": "order_1042",
"refunded": true,
"refundId": "refund_77"
}
The amount means one of two things, depending on refundId:
- With
refundId, the amount is this one refund. Partial refunds add up, one call each. A call with arefundIdthat was already applied answersskipped (already refunded), so retries are safe. A refund that would take the total past the payment's amount is cut down to what is left, and once nothing is left the answer isskipped (already refunded). - Without
refundId, the amount is the total refunded so far for this payment. Only the increase over what was already refunded is applied. Sending the same total again changes nothing and answersskipped (already refunded), and a full refund repeats the payment's amount.
A refund for a transactionId that was never recorded in this audience writes nothing and answers
transaction not found. If the refund reached you before the payment did, send it again once the
payment is in.
A refund is recorded as a negative amount at the time of the refund (its timestamp, or the time of
the call), converted at the same exchange rate as the original payment. It reduces revenue in the
period it falls in, and it never counts as a conversion.
The answer
Every outcome answers 200 with JSON naming what happened and the transactionId:
message | What it means |
|---|---|
attributed | Recorded, and tied to a visitor or to an identified person. |
recorded | Recorded, but tied to no one. It counts in your totals and under Direct. |
skipped (duplicate) | A payment with this transactionId is already recorded. Nothing was written. |
refunded | The refund was recorded. |
skipped (already refunded) | Nothing left to refund: this refundId was already applied, the total did not grow, or the payment is fully refunded. |
transaction not found | A refund for a payment that was never recorded in this audience. Nothing was written. |
Read message rather than the status: a duplicate and a refund of an unknown payment are both 200.
Errors
Errors answer { "status": 400, "title": "Bad Request", "detail": "..." }, with the status and
detail of the problem.
| Status | When |
|---|---|
400 | A field is missing or invalid, and detail says which, for example transactionId is required, revenue.currency must be a 3-letter ISO 4217 code or timestamp must not be in the future. Also a field this route does not accept, and No team selected. Send an X-Team-Id header or set a default team. when no workspace can be worked out. |
401 | No Authorization header, a header that does not start with Bearer , or a key that does not exist. The detail starts with Not authorized to access this resource. |
403 | The key's owner is a Viewer in the workspace: This action needs the editor role. |
410 | Audience not found: the audience does not exist, or is not in the workspace the call acts on. Resource not found: X-Team-Id names a workspace you do not belong to. |
429 | More than 10 requests in flight at once across your account keys. Requests sent one after another never reach this. |
500 | Something failed on the way. Send the same request again: its transactionId makes the retry safe. |
Request
curl -X POST 'https://api.spreeflo.com/1.0/revenue' \
-H 'Authorization: Bearer YOUR_ACCOUNT_KEY' \
-H 'Content-Type: application/json' \
-d '{
"audienceId": "8f14e45f-ceea-4e7a-9b2c-1d5a3f6b7c8d",
"eventName": "purchase",
"revenue": { "amount": 49.9, "currency": "USD" },
"transactionId": "order_1042",
"visitorId": "vid_6f1c2a4e-8d3b-4c55-9a7e-2b8f0c9d1e23",
"email": "ada@example.com",
"items": [{ "name": "Pro plan", "amount": 49.9 }],
"timestamp": "2026-10-01T09:40:00.000Z"
}'
// On your server, once the payment has gone through. The visitor id was read
// in the browser with Spreelytics.getVisitorId() and saved with the order.
const res = await fetch('https://api.spreeflo.com/1.0/revenue', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SPREELYTICS_ACCOUNT_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
audienceId: '8f14e45f-ceea-4e7a-9b2c-1d5a3f6b7c8d',
eventName: 'purchase',
revenue: { amount: order.total, currency: order.currency },
transactionId: order.id,
visitorId: order.metadata.visitorId,
email: order.customerEmail,
}),
});
const { message } = await res.json(); // "attributed", "recorded", "skipped (duplicate)", ...
# On your server, once the payment has gone through. The visitor id was read
# in the browser with Spreelytics.getVisitorId() and saved with the order.
import os
import requests
res = requests.post(
"https://api.spreeflo.com/1.0/revenue",
headers={"Authorization": f"Bearer {os.environ['SPREELYTICS_ACCOUNT_KEY']}"},
json={
"audienceId": "8f14e45f-ceea-4e7a-9b2c-1d5a3f6b7c8d",
"eventName": "purchase",
"revenue": {"amount": order.total, "currency": order.currency},
"transactionId": order.id,
"visitorId": order.metadata["visitorId"],
"email": order.customer_email,
},
)
message = res.json()["message"]
Response
{
"message": "attributed",
"transactionId": "order_1042"
}
Related
- Revenue: connecting a payment provider, attribution models and currencies.
- Getting started: account keys, the audience id and limits.
- Goals: goals on your payment event carry the revenue it brought in.