# Conversion API

> The endpoint that receives sales – for server-side and custom integrations, with parameters, responses and limits.

Every sale from [track.js](https://www.affilyflow.com/en/docs/reference/track-js) ends up at the same endpoint. If you build the integration yourself – e.g. from your own backend once payment has gone through – you can call it directly.

```http title="Endpoint"
POST https://app.affilyflow.com/api/track/conversion
GET  https://app.affilyflow.com/api/track/conversion
```

- Both `GET` and `POST` work. `OPTIONS` answers CORS preflight requests.
- CORS is open (`Access-Control-Allow-Origin: *`), so the endpoint can be called from any browser.
- No key is required. What ties the sale to an affiliate is the click ID – or a [personal discount code](#personal-discount-codes).

## The click ID first

Without the click ID the sale can't be attributed to anyone. It arrives from the affiliate link as `?afid=` on your site, and track.js stores it in the `af_click` cookie on your domain. Your server can therefore read the cookie from the customer's request when the order is created and send it along.

If you don't have track.js on the site, store `afid` from the URL yourself. Only accept values that look like ours: `c_` followed by 4–64 characters (letters, digits, `_` and `-`).

> [!TIP]
> Save the click ID on the order when it is created. Then the sale can be reported from the server even when payment is confirmed later and the customer never sees the receipt.

## Parameters

| Parameter | Alias | Required | Meaning |
|---|---|---|---|
| `afid` | `click_id` | Yes, when there is a click | The click ID (`c_…`). Without a click, a personal discount code in `coupon`/`coupons` can attribute the sale. |
| `store` | – | Recommended | Your store ID (the same as `data-store`). Must match the click's store, otherwise `store_mismatch`. |
| `order` | `order_id` | Recommended | The order number. The same number is never counted twice in the same program. At most 120 characters. |
| `total` | `order_total` | Yes | The amount commission is calculated on: goods after discount, excluding VAT and shipping. A machine number (`1249.5`). |
| `currency` | – | No | ISO 4217 currency code, e.g. `DKK` or `EUR`. Without it, the store's settlement currency is used. |
| `coupon` | – | No | The customer's discount code(s) as text, separated by commas (e.g. `ANNA10,SUMMER`). In a JSON body you can also send `coupons` as a list. |

> [!WARNING]
> The endpoint reads `total` as a machine number. `"1.249,00"` is not a number here and becomes 0. Only track.js cleans Danish amounts – send `1249` or `1249.00` from your server.

Legacy direct links without a click ID can send `aff` (or `aff_id`) – the affiliate's tracking ID – together with `store` instead. The affiliate must then have joined the store's program, otherwise the response is `not_joined`.

## GET

Parameters in the URL. This is what track.js uses as a fallback when the browser has no `sendBeacon`.

```bash title="GET"
curl "https://app.affilyflow.com/api/track/conversion?afid=c_AbC123xYz_-9&store=DIN_BUTIK&order=1008&total=499&currency=DKK"
```

## POST

Send the fields as JSON in the body. The fields can also be in the URL – if a field is in both, the body wins. This is how track.js sends with `navigator.sendBeacon`: a `POST` with the fields in the URL and no body.

```bash title="POST with JSON"
curl -X POST https://app.affilyflow.com/api/track/conversion \
  -H "Content-Type: application/json" \
  -d '{"afid":"c_AbC123xYz_-9","store":"DIN_BUTIK","order":"1008","total":499,"currency":"DKK"}'
```

```js title="Node · after payment"
await fetch('https://app.affilyflow.com/api/track/conversion', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    afid: order.afClick,       // saved from the af_click cookie
    store: 'DIN_BUTIK',
    order: order.number,
    total: order.subtotalExVat, // goods after discount, excl. VAT and shipping
    currency: order.currency
  })
});
```

The body may be at most 64 KB.

### Optional body fields

| Field | Meaning |
|---|---|
| `items` | Item lines: a list of `{ title, variant, qty, price, product_id }`. `title` is required per line. At most 50 lines; long text is truncated. |
| `subtotal`, `tax`, `shipping` | The order breakdown (numbers, 0 or above). See below. |
| `buyer_hash` | Fingerprint of the buyer's email: `hex(sha256(lowercase(trim(email))))` – 64 hex characters. If it matches the affiliate's email, the sale is recorded as rejected (`self_referral`). The email itself is never sent. |
| `coupons` | The customer's discount codes as a list of strings, e.g. `["ANNA10"]`. See [Personal discount codes](#personal-discount-codes). |
| `source` | Where the sale comes from. Send `"server"` when your own server reports the sale. |

**The breakdown:** if you send `total` as the order's full amount and include `subtotal`, commission is calculated on `subtotal` instead – but never on more than `total`:

- If `tax` is 0 or missing, or `subtotal + shipping + tax = total` (VAT added on top), the base is `subtotal`.
- If `subtotal + shipping = total` (VAT included in prices), the base is `subtotal × (total − tax) / total`.
- Otherwise the cautious `min(subtotal, total − tax − shipping)` is used.

The simplest is to send `total` as the goods amount excluding VAT and shipping and leave out the breakdown.

For the [Google Analytics route](https://www.affilyflow.com/en/docs/reference/google-analytics#the-server-recalculates), track.js also sends `src=ga4` and the event's raw figures – `gv` (value), `gs` (shipping), `gi` (item lines' sum) and `gt` (tax) – so the server can recalculate the amount with the store's setting. Don't send them yourself; only sales marked `src=ga4` are recalculated.

## Reporting from your server

Report the sale from your backend once the order is paid:

1. **At checkout** – while it is the customer's own browser calling you – save the value of the `af_click` cookie on the order.
2. **When the order is paid** – e.g. in the payment callback or webhook – `POST` the order with `source: "server"`, `store`, `afid` (if any), `order`, `total`, `currency`, `coupons` and `buyer_hash`.

The browser report (the script or the sale line) can stay as a backup: the same order number only counts once. The store dashboard's Setup has ready-made snippets for Laravel, PHP, Node.js and Next.js with your store ID filled in.

## Personal discount codes

A personal discount code is a code the store has assigned to one affiliate under **Program → Discount codes** in the store dashboard. Send the customer's codes in `coupon` (text) or `coupons` (list):

- **Valid click:** the click decides the sale, as always. A valid click always wins over a code.
- **No click, or the click has expired or is unknown:** if one of the codes is personal, the sale is attributed to the affiliate the code belongs to. `store` must be included.
- **No personal code:** nothing is recorded, and the response is `no_coupon`. A general code for all affiliates earns no commission on its own.

Codes are compared without spaces and in upper case. At most 10 codes per sale are used. Commission for a sale via a code is the program's current rate – there is no click to lock it on. All other guards apply as for a click.

```bash title="POST · personal discount code without a click"
curl -X POST https://app.affilyflow.com/api/track/conversion \
  -H "Content-Type: application/json" \
  -d '{"source":"server","store":"DIN_BUTIK","order":"1008","total":499,"currency":"DKK","coupons":["ANNA10"]}'
```

## Responses

The response is always JSON with a `status`. What each status means and what to do is in [Status codes](https://www.affilyflow.com/en/docs/reference/status-codes).

| HTTP | Body | When |
|---|---|---|
| 200 | `{ "status": "ok" }` | The sale is recorded. |
| 200 | `{ "status": "duplicate" }`, `"expired"` etc. | The request was processed, but the sale earned no commission. |
| 200 | `{ "status": "no_coupon" }` | No valid click, and none of the codes is a personal code. Nothing is recorded. |
| 404 | `{ "status": "unknown" }` | The click ID doesn't exist. |
| 400 | `{ "status": "error", "reason": "…" }` | Neither `afid`, `aff` + `store` nor a discount code + `store` was included. |
| 413 | `{ "status": "too_large" }` | The body was over 64 KB. |
| 429 | `{ "status": "rate_limited" }` | Too many calls from the same IP address. |
| 500 | `{ "status": "error" }` | Recording failed. Try again. |

A recorded sale awaits the store's approval. Commission is only credited once the sale is approved.

## Duplicates and order numbers

- The same order number in the same program is never counted twice – the second call responds `duplicate`. It is therefore safe to resend when in doubt.
- Without an order number there is no duplicate check on the server. Always send the order number.
- An unfilled placeholder (`DIN_ORDRE_ID`, `ORDER_ID`, `{{ … }}`, `[ … ]`, `< … >`, `% … %` etc.) is treated as no order number, so it never takes up the number for every later sale.

## Currency

The sale can be in any currency with a rate from the European Central Bank. It is converted to the store's settlement currency before commission and fee are calculated, and the rate is locked on the sale. A currency code that isn't three letters, or a currency without a rate from the last 10 days, is rejected as `unsupported_currency`.

## Limits

- The endpoint is rate limited per IP address. The limit doesn't affect a store's own server in normal use; the response is `429` with `rate_limited` if it is reached.
- Each program has an amount cap per order (50,000 in the settlement currency by default). An order above the cap is rejected as `value_too_high` – typically an amount sent in cents.
- Long values are truncated rather than rejected: click ID 64 characters, store and order number 120 characters.

## Refunds

The endpoint only reports sales – there is no public route for reporting a refund. If an order is refunded, the store rejects the sale under **Sales** in the dashboard within the program's approval period. The WooCommerce plugin and the Stripe webhook report refunds on their own, partial ones too – see [Approval and refunds](https://www.affilyflow.com/en/docs/store/approval-and-refunds).

Source: https://www.affilyflow.com/en/docs/reference/conversion-api
