The SDK is the recommended path for JavaScript and TypeScript stacks. If you're on a different runtime — PHP, Python, Ruby, Go, Elixir — or you just don't want a dependency, call the public REST endpoints directly. This guide covers the pattern most merchants use: a server-to-server postback fired from your checkout backend after a successful payment.

## When to use postback

Postback is the right pattern when:

* The conversion happens server-side (Stripe webhook, PayPal IPN, custom payment processor).
* You need delivery guarantees stricter than what a browser pixel can offer.
* You operate on a non-JS stack.
* The browser session may be lost between the click and the conversion (Safari ITP, cross-device, mobile app).

If the conversion is tied to a page the customer is currently visiting, the [SDK](/docs/api/integration/sdk) is simpler.

## Endpoint

```http
POST /api/v1/conversions/postback
Authorization: Bearer <YOUR_API_KEY>
Content-Type: application/json
```

## Payload

```json
{
  "externalId": "ord_42",
  "conversionType": "SALE",
  "amount": 4990,
  "currency": "EUR",
  "sessionId": "rcs_7fKj2...",
  "metadata": { "plan": "pro" }
}
```

| Field                 | Type           | Required | Notes                                                                                              |
| --------------------- | -------------- | -------- | -------------------------------------------------------------------------------------------------- |
| `externalId`          | string (1–255) | yes      | Your order / transaction id. Acts as the idempotence key.                                          |
| `conversionType`      | string         | yes      | One of `SALE`, `LEAD`, `TRIAL`, `CUSTOM`.                                                          |
| `amount`              | integer        | yes      | Conversion value in **minor units (cents)**. e.g. €49.90 → `4990`. Decimals are rejected with 422. |
| `currency`            | string         | yes      | ISO 4217 3-letter code (upper-cased server-side).                                                  |
| `sessionId` / `rcsid` | string         | one of   | Tracking session id from the click cookie. Preferred when intact.                                  |
| `clickId` / `cid`     | string         | one of   | Click id returned by the tracking URL. Alternative to `sessionId`.                                 |
| `customerEmailHash`   | string         | one of   | SHA-256 hex of the lowercased customer email. Fallback when session is lost.                       |
| `customerEmail`       | string         | one of   | Customer email in plaintext (hashed server-side). Fallback.                                        |
| `customerId`          | string         | no       | Your internal customer id.                                                                         |
| `convertedAt`         | string         | no       | ISO 8601 datetime of the conversion. Defaults to now.                                              |
| `customParams`        | object         | no       | Up to 5 custom string params: `{ s1, s2, s3, s4, s5 }`.                                            |
| `metadata`            | object         | no       | Free-form. Persisted on the conversion, surfaced in exports.                                       |

At least one attribution identifier (`sessionId`/`rcsid`, `clickId`/`cid`, `customerEmailHash`, or `customerEmail`) must be present. If multiple are passed, `clickId` → `sessionId` → `customerEmailHash` → `customerEmail` in priority order.

## Compute the email hash

<Callout type="warning" title="Hash on the server, not the client">
  The hash is a fallback identifier; if you compute it in the browser, an attacker can replay it to attribute conversions to themselves. Always hash on your server, after you've verified the email belongs to the paying customer.
</Callout>

```python
import hashlib
email = customer_email.lower().strip()
email_hash = hashlib.sha256(email.encode()).hexdigest()
```

```php
$emailHash = hash('sha256', strtolower(trim($email)));
```

```ruby
require 'digest'
email_hash = Digest::SHA256.hexdigest(email.downcase.strip)
```

The hash is what we store. The plaintext email never leaves your infrastructure.

## Example: Stripe webhook handler

A typical pattern: your server already receives a Stripe `checkout.session.completed` webhook. Extend the handler to fire a RefCampaign postback.

```python
# Python / Flask
import os
import requests

REFCAMPAIGN_KEY = os.environ['REFCAMPAIGN_API_KEY']
REFCAMPAIGN_URL = 'https://app.refcampaign.com/api/v1/conversions/postback'

def on_stripe_completed(session):
    requests.post(
        REFCAMPAIGN_URL,
        headers={
            'Authorization': f'Bearer {REFCAMPAIGN_KEY}',
            'Content-Type': 'application/json',
        },
        json={
            'externalId': session.id,
            'conversionType': 'SALE',
            'amount': session.amount_total,  # already in cents
            'currency': session.currency.upper(),
        },
        timeout=10,
    )
```

## Idempotency and retries

The endpoint is **idempotent on `externalId`** — a repeat call with the same `externalId` for the same merchant returns the existing conversion (`{ success: true, conversionId, message: "Conversion already tracked" }`) without creating a duplicate. If the same `externalId` is submitted by a *different* merchant, the server returns `409`.

If your webhook handler retries on transient failure, this is safe — sending the same `externalId` again never creates a duplicate conversion.

For network-level retries: respect the `Retry-After` header on `429`, treat `5xx` as transient and back off, treat `4xx` as terminal (no retry, log + alert).

## Verifying delivery

The merchant dashboard shows incoming conversions in real time. For automated pipelines, query the conversion list to confirm the row landed:

```bash
curl https://app.refcampaign.com/api/v1/conversions?campaignId=cmp_xxx&limit=10 \
  -H "Authorization: Bearer $REFCAMPAIGN_API_KEY"
```

## Security

* Send the API key only over HTTPS.
* Store it in a secret manager — never in source code.
* Use a dedicated key per environment (production / staging / local).
* See [authentication](/docs/api/authentication) for the full checklist.
