Simmer Docs

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/conversion
Authorization: Bearer simmer_sk_<your_secret>
Content-Type: application/json

Get your secret key

In the dashboard, go to Settings → Server-side API key → Generate secret key.

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

FieldTypeRequiredNotes
project_idstringyesYour project ID.
order_idstringyesYour unique order ID (1–128 ASCII chars). Used as the idempotency key — safe to retry.
emailstringyesBuyer email. Hashed server-side with your project HMAC; raw email is never stored.
total_revenuenumberyesOrder total. Must be 0 ≤ x ≤ 1,000,000.
currencystringyes3-letter ISO 4217 (uppercase), e.g. USD.
occurred_atstringyesISO-8601 datetime with timezone (e.g. 2026-05-05T10:30:00Z). Must be within the last 7 days.
anonymous_idstringnoThe visitor's pixel anonymous ID, if you can read the cookie. Strongly recommended — without it, attribution falls back to email-only matching.

Response

{
  "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/conversion", {
  method: "POST",
  headers: {
    authorization: `Bearer ${process.env.LETSIMMER_SECRET_KEY}`,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    project_id: "proj_xxx",
    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/conversion", {
  method: "POST",
  headers: {
    authorization: `Bearer ${process.env.LETSIMMER_SECRET_KEY}`,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    project_id: "proj_xxx",
    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 order:{project_id}:{order_id} and deduplicated server-side — the latest write wins, so retries (e.g. on a 502 from a transient downstream failure) are safe.

Errors

StatusMeaning
400Body validation failed — see the error field in the response body.
401Missing or invalid Authorization: Bearer <key>, or no key configured for the project.
404Unknown project_id.
429Rate limit exceeded — per-project default is 50/min.
502Downstream write failed; safe to retry.

Quick test

curl -i -X POST https://app.letsimmer.com/api/conversion \
  -H "Authorization: Bearer $LETSIMMER_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"project_id":"proj_xxx","order_id":"ORD-TEST-1","email":"buyer@example.com","total_revenue":99,"currency":"USD","occurred_at":"2026-05-05T10:00:00Z"}'

Expected: 200 OK with { "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 secret key (simmer_sk_…) 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/collect and /api/identify.
  • Email is hashed server-side; raw email is never stored.
  • Logs include project_id and order_id only — never email or revenue.
  • The endpoint accepts only HTTPS in production.

Troubleshooting

401 Invalid secret key on every request. Double-check that the bearer is the exact simmer_sk_… value from generation (no surrounding whitespace, no email obfuscator wrapping, no Bearer Bearer typo).

400 occurred_at must be an ISO-8601 datetime with timezone. The endpoint rejects date-only strings ("2026-05-05") and timezone-less timestamps ("2026-05-05T10:30:00"). Always include Z (UTC) or an offset (+02:00). Date-only ambiguity at midnight UTC was causing channel-daily aggregates to land on the wrong day — strict timezone is intentional.

200 OK 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.

On this page