Server-side Conversions
Record purchases from your backend when you don't have HubSpot or Shopify.
If your business doesn't fit HubSpot's CRM model or Shopify's checkout, the Server-side Conversion API is how you tell Simmer that a payment happened. Your backend POSTs once per completed order — typically from your Stripe (or Lemon Squeezy / Paddle / Chargebee / etc.) webhook handler — and Simmer records the revenue, runs attribution against the buyer's browsing history, and surfaces the channel breakdown in the dashboard.
This is server-to-server only. The bearer token is a real secret — never expose it in browser code.
Endpoint
POST https://app.letsimmer.com/api/v1/conversions
Authorization: Bearer simmer_api_<your_api_key>
Content-Type: application/jsonCompatibility
The older POST /api/conversion endpoint remains available for existing integrations that use simmer_sk_... keys and include project_id in the request body. New integrations should use POST /api/v1/conversions.
Get your API key
In the dashboard, create a Public API key with the conversions:write scope.
You'll see the full key once. Copy it into your server's secret manager (Stripe Dashboard env, Vercel env, AWS Secrets Manager — wherever you keep STRIPE_SECRET_KEY). Simmer stores only a SHA-256 hash; if you lose the key, rotate from the same screen.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
order_id | string | yes | Your unique order ID (1–128 ASCII chars). Used as the idempotency key — safe to retry. |
email | string | yes | Buyer email. Hashed server-side with your project HMAC; raw email is never stored. |
total_revenue | number | yes | Order total. Must be 0 ≤ x ≤ 1,000,000. |
currency | string | yes | 3-letter ISO 4217 (uppercase), e.g. USD. |
occurred_at | string | yes | ISO-8601 datetime with timezone (e.g. 2026-05-05T10:30:00Z). Must be within the last 7 days. |
anonymous_id | string | no | The visitor's pixel anonymous ID, if you can read the cookie. Strongly recommended — without it, attribution falls back to email-only matching. |
Response
{
"data": {
"status": "accepted",
"conversion_id": "order:proj_xxx:ORD-12345",
"attribution_deferred": false
}
}attribution_deferred: true means the project is currently being reprocessed — the conversion row is recorded, and journey/channel attribution will run after the rebuild completes.
Capturing anonymous_id
The pixel sets a first-party cookie simmer_id=<anonymous_id> on your domain when consent is granted. The conversion POST originates from your server (e.g. a Stripe webhook), which has no browser cookies — so you must ferry the value from the browser to your backend yourself.
Same-domain backend (easy case)
If the request that triggers the conversion (e.g. a /checkout/complete POST) hits your own server, just read the cookie:
import { cookies } from "next/headers"; // or req.cookies in Express, etc.
const anonymousId = cookies().get("simmer_id")?.value;
await fetch("https://app.letsimmer.com/api/v1/conversions", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.LETSIMMER_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
order_id: order.id,
email: customer.email,
anonymous_id: anonymousId,
total_revenue: order.amountTotal / 100,
currency: order.currency.toUpperCase(),
occurred_at: new Date().toISOString(),
}),
});Stripe Checkout / hosted payment page (most common)
The buyer leaves your domain to pay, so the cookie isn't on Stripe's webhook request. Stash the value in Stripe's metadata when creating the session, then read it back in the webhook:
// When creating the Checkout Session:
const session = await stripe.checkout.sessions.create({
// ...
metadata: {
simmer_anonymous_id: getCookie("simmer_id") ?? "",
},
});// In your Stripe webhook handler for checkout.session.completed:
const sess = event.data.object as Stripe.Checkout.Session;
await fetch("https://app.letsimmer.com/api/v1/conversions", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.LETSIMMER_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
order_id: sess.id,
email: sess.customer_details?.email,
anonymous_id: sess.metadata?.simmer_anonymous_id || undefined,
total_revenue: (sess.amount_total ?? 0) / 100,
currency: (sess.currency ?? "usd").toUpperCase(),
occurred_at: new Date(sess.created * 1000).toISOString(),
}),
});The same pattern works for any third-party checkout that supports passthrough metadata — Lemon Squeezy custom_data, Paddle customData, Chargebee cf_* fields, and so on.
Don't pass it (degraded, but still works)
If you can't bridge the cookie, omit anonymous_id. Simmer still:
- Records the conversion row with revenue.
- Attempts attribution via the hashed email against any existing identity links — prior
simmer.identify(email)calls, prior form submissions, etc.
If no identity link exists for that email, the conversion shows up in revenue totals but produces an empty journey — channel attribution will not be available for that order. Pass anonymous_id whenever you can to avoid this.
Idempotency
Repeated POSTs with the same order_id will not double-count revenue. The conversion row is keyed by your project and order ID, then deduplicated server-side — the latest write wins, so retries (e.g. on a 502 from a transient downstream failure) are safe.
Errors
| Status | Meaning |
|---|---|
| 400 | Body validation failed — see error.message in the response body. |
| 401 | Missing or invalid Authorization: Bearer <key>. |
| 403 | The API key is missing the conversions:write scope. |
| 404 | The API key's project was not found. |
| 429 | Rate limit exceeded for this API key. |
| 502 | Downstream write failed; safe to retry. |
Quick test
curl -i -X POST https://app.letsimmer.com/api/v1/conversions \
-H "Authorization: Bearer $LETSIMMER_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"order_id\":\"ORD-TEST-1\",\"email\":\"buyer@example.com\",\"total_revenue\":99,\"currency\":\"USD\",\"occurred_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"Expected: 202 Accepted with { "data": { "status": "accepted", "conversion_id": "order:proj_xxx:ORD-TEST-1", "attribution_deferred": false } }. Run it again and you'll get the same conversion_id back — that's the idempotency guard working.
Security notes
- The Simmer API key (
simmer_api_…) is secret — keep it in your server's secret manager. Do not commit it. Do not expose it to the browser. - The publishable
data-ingest-key(ik_…) on your pixel<script>tag is not a secret and cannot be used to call this endpoint — it is only accepted by/api/collectand/api/identify. - Email is hashed server-side; raw email is never stored.
- Logs include request identifiers only — never email or revenue.
- The endpoint accepts only HTTPS in production.
Troubleshooting
401 Invalid API key on every request.
Double-check that the bearer is the exact simmer_api_… value from generation (no surrounding whitespace, no email obfuscator wrapping, no Bearer Bearer typo).
400 Invalid conversion request body.
Check that every field matches the request body table above. occurred_at must be an ISO-8601 datetime with Z or an offset, not a date-only string or timezone-less timestamp.
202 Accepted returns but the conversion doesn't show in the dashboard.
Check the response: if attribution_deferred: true, the project is being reprocessed. Otherwise, confirm the email matches an identity that has browsing history (see Capturing anonymous_id above). A conversion with no identity link is recorded in revenue totals but has no attributed channel.
Repeated POSTs increment revenue.
They shouldn't. If you see this, the order_id is changing between retries (e.g. a fresh UUID per retry attempt). Use a stable identifier — your payment processor's checkout-session ID is ideal.
Channel Value overview
Once conversions are flowing, attributed revenue appears in the Channel Value overview. When you have an active conversions:write API key and no HubSpot integration, the overview uses order-date attribution: selecting "Last 30 days" shows conversions that occurred in that window and distributes credit across the channels in each buyer's journey. Conversions with no stitched journey show as unattributed and are excluded from the per-channel breakdown.
Leads and SQLs are not applicable in this mode — their columns are hidden from the Channel Value table. The funnel shows conversion revenue and channel credit rather than a lead-cohort progression.