Developer
Conversions API
Post completed orders from your server so revenue lands against its true source.
What it is for
The tag on your pages measures visits. It cannot see an order, because the order happens on your server. This endpoint is how you tell us one happened, so a conversion and its revenue land against the source that brought the visit.
Call it once per completed order, from your backend. Never from the browser: it is signed with a secret, and a secret in page source is not a secret.
Before you start
Collecting from your own site is part of the paid plans. It is the one capability whose cost follows your traffic, so it stays with an active subscription: if the subscription lapses, collection pauses and this endpoint answers 402. Data already collected stays yours, and collection resumes when the subscription is active again.
Credentials
Adding a site in Settings gives you two values that are not interchangeable:
- Site key - public. It ships inside the snippet on your pages. It identifies a site.
- Secret - private. It is stored only as a digest, so it is shown once, at creation. It
authorizes revenue.
Lost the secret? Add the site again and replace the snippet. We cannot recover it, by design.
The request
POST https://seomadman.com/api/collect/conversion
Content-Type: application/json
{
"k": "your-site-key",
"s": "your-secret",
"p": "/checkout/complete",
"c": "ai",
"v": 99.5,
"id": "order-10024"
}| Field | Required | Meaning |
|---|---|---|
k | yes | Site key from Settings |
s | yes | Secret from Settings |
p | no | Page the conversion belongs to, defaults to / |
c | yes | Source class: ai, search, social, referral, direct |
v | no | Order value in your currency, defaults to 0 |
id | yes | Your order id, deduplicates retries |
Where c comes from
This is the field people ask about. We refuse to guess it: your backend knows which visit converted, we do not, and inventing a source would put an estimate in a column that reads as exact.
The snippet already worked the class out and parked it in session storage under _ac. Read it in the browser and send it along with the order:
// in your checkout form or fetch payload
const source = sessionStorage.getItem("_ac") || "direct";If checkout happens on a different domain from the tagged pages, session storage does not travel: carry the value yourself, in the URL or in your own session record.
Retries and duplicates
id is required because payment webhooks retry. Send the same id again and the conversion is recorded once. This is checked before anything else is counted, so a retry storm costs you nothing and cannot inflate a figure.
Responses
| Code | Body | Meaning |
|---|---|---|
202 | {"status":"ok"} | Recorded |
200 | {"status":"duplicate"} | Already recorded under this id |
400 | {"status":"invalid"} | Missing id, or c is not a valid class |
401 | {"status":"unknown"} | Site key or secret is wrong |
402 | {"status":"unpaid"} | Subscription is not active, collection is paused |
429 | {"status":"capped"} | Monthly cap reached, retry later |
503 | {"status":"error"} | Temporary fault, safe to retry |
A wrong key and a wrong secret answer identically, so neither can be found by probing. Retrying any failure is safe, because id makes the call idempotent.
Worked example
curl -X POST https://seomadman.com/api/collect/conversion \
-H 'content-type: application/json' \
-d '{"k":"SITE_KEY","s":"SECRET","p":"/checkout/complete","c":"ai","v":99.5,"id":"order-10024"}'In a Stripe webhook handler, send it after you have verified Stripe's signature and the payment has actually succeeded, using the payment intent id as id:
await fetch("https://seomadman.com/api/collect/conversion", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
k: process.env.SEOMADMAN_SITE_KEY,
s: process.env.SEOMADMAN_SECRET,
p: "/checkout/complete",
c: order.source || "direct",
v: order.total,
id: paymentIntent.id,
}),
});Limits and honesty
Order values are clamped to a sane range on our side, so a typo cannot book an absurd figure that then has to be unpicked by hand.
Conversions count against the same monthly collection cap as pageviews. The cap exists so the service cannot run up a surprise bill; when it is reached we stop recording rather than keep spending, and say so with a 429.
We store the counters and nothing else. No customer identity, no email, no order contents, no IP address. The only fields that reach us are the six above.