# Send server-side conversions

Attribute events your browser never sees, such as payment webhooks, OAuth callbacks and queue workers, by stitching them to the visitor who caused them.

Source: https://sourceloop.ai/help/api/server-side-conversions/

---

Some conversions never touch the browser. A subscription renews on your billing provider's schedule, a trial converts inside a queue worker, a deal closes in your admin panel. `POST /v1/events` is how those still get attributed.

Events sent this way are validated and classified with exactly the same rules the browser tracker applies, so a server-side conversion attributes identically to a client-side one. This is not a second, parallel pipeline.

## The one thing that decides whether it works

An event needs an identity to attach to. Send **at least one** of:

- `anonymous_id`, the visitor's `_sl_aid` cookie value
- `email`
- `phone`

`anonymous_id` is the strong one. It is what stitches a backend event to the browsing history that preceded it, which is the whole point: without it, a purchase that started with a Google Ads click looks like it came from nowhere.

So the pattern is: **read the cookie while you still have a request from the visitor, store it with your own record, and send it back later.**

```ts
// At signup, in your web app, where you still have the request.
const anonymousId = req.cookies["_sl_aid"];
await db.user.update({ where: { id }, data: { anonymousId } });
```

```ts
// Weeks later, in the billing webhook, where you do not.
await fetch("https://app.sourceloop.ai/api/v1/events", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SOURCELOOP_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": invoice.id,
  },
  body: JSON.stringify({
    website: "acme.com",
    events: [
      {
        event_type: "custom",
        event_name: "subscription_started",
        anonymous_id: user.anonymousId,
        email: user.email,
        occurred_at: new Date(invoice.created * 1000).toISOString(),
        revenue: invoice.amount_paid / 100,
        currency: invoice.currency.toUpperCase(),
        properties: { plan: invoice.plan_name },
      },
    ],
  }),
});
```

Send `email` as well when you have it. It gives us a second way to resolve the same person if the cookie was cleared between the visit and the event.

## Getting the details right

**Set `occurred_at` from the source event, not from when your worker ran.** A webhook retried three hours later will otherwise land in the wrong day, and a wrong day means the wrong campaign gets the credit.

**Send `revenue` in major units** (49.00, not 4900) with an explicit `currency`. Mixed-currency workspaces convert on our side using the workspace currency, and an unlabelled number cannot be converted correctly.

**Use `Idempotency-Key`.** Payment providers retry webhooks. Keying on the invoice or charge id means a retry returns the original result instead of double-counting revenue.

**Batch up to 200 events per request.** A batch is partial-success: one malformed event is rejected and reported, the rest are accepted. Always read the response counts rather than assuming a `200` means everything landed.

```json
{ "accepted": 199, "rejected": 1, "errors": [{ "index": 42, "detail": "occurred_at is not a valid timestamp" }] }
```

## Backfilling history

The same endpoint accepts historical events, so you can send the last few months when you first connect. Two limits to plan around: batches cap at 200 events, and the workspace rate limit is counted per minute across every key. Pace the backfill rather than firing it as fast as your loop allows, and watch `RateLimit-Remaining` on the way through. See [Errors and rate limits »](/help/api/errors/).

Backfilled events attribute only as well as the identity you send with them. Rows with no `anonymous_id` and no email will land as conversions without a source, which is honest but not useful, so it is usually worth exporting the cookie value alongside the record before you start.

## Checking it worked

Call `GET /v1/contacts` filtered to a recent window and look for the contact, or open the Contacts Hub in the app. The conversion should carry a first touch, and that first touch should be the campaign you expect rather than "Direct".

If it says Direct, the identity did not resolve. That is almost always a missing `anonymous_id`, and almost never a problem with the event itself.

Next: [sync data incrementally »](/help/api/incremental-sync/) to pull outcomes back out.