Postback and the SDK push data **into** RefCampaign. Webhooks are the reverse: RefCampaign POSTs a signed JSON event **to your server** the moment a conversion lifecycle event happens — so you can sync a CRM, trigger a Slack or Zapier automation, or feed a data pipeline without polling.

Webhooks are available from the **Growth** plan.

## Enable a webhook

In the dashboard, go to **Settings → Webhooks → Add webhook**:

1. Enter your endpoint URL (must be **HTTPS**).
2. Pick the events you want to receive.
3. Save. The signing secret (`whsec_…`) is shown **once** — store it now.

You can configure up to **5 webhooks** per account, each with its own secret and event subscriptions. Use **Send test** to fire a one-off `webhook.test` ping and confirm your endpoint is reachable, and **Rotate secret** to issue a new signing secret.

## Events

| Event                         | Fires when                          |
| ----------------------------- | ----------------------------------- |
| `conversion.created`          | A conversion is recorded.           |
| `conversion.refunded`         | A conversion is refunded.           |
| `conversion.commission_paid`  | A commission is marked as paid.     |
| `conversion.disputed`         | A conversion's payment is disputed. |
| `conversion.dispute_resolved` | A payment dispute is resolved.      |

## Payload

Every delivery is a `POST` with a JSON body of this shape:

```json
{
  "event": "conversion.created",
  "data": { "conversionId": "conv_abc", "amount": 4990, "currency": "EUR" },
  "timestamp": "2026-06-19T12:00:00.000Z",
  "idempotencyKey": "conversion.created:conversion:conv_abc"
}
```

| Field            | Type   | Notes                                                                                                                         |
| ---------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------- |
| `event`          | string | One of the event types above.                                                                                                 |
| `data`           | object | Event-specific payload.                                                                                                       |
| `timestamp`      | string | ISO 8601 emission time.                                                                                                       |
| `idempotencyKey` | string | Stable per `(event, resource)`. Use it to dedupe — the same lifecycle event always carries the same key, even across retries. |

## Verify the signature

Every request carries an `X-RefCampaign-Signature` header, using the same scheme as Stripe:

```
X-RefCampaign-Signature: t=<unix_seconds>,v1=<hex_sha256>
```

The signature is `HMAC-SHA256(secret, "<t>.<rawBody>")`. **Always verify it on the raw request body, before parsing JSON** — a re-serialized body will not match.

<Callout type="warning" title="Verify before you trust">
  An unverified webhook can be forged by anyone who learns your URL. Reject any request whose signature does not validate, and any whose timestamp is outside a tolerance window (5 minutes is the default) to block replays.
</Callout>

On JS/TS stacks, the SDK ships a helper:

```ts
import { verifyWebhookSignature, WEBHOOK_SIGNATURE_HEADER } from '@refcampaign/sdk'

// Express — note express.raw() to keep the body unparsed
app.post('/webhooks/refcampaign', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyWebhookSignature({
    secret: process.env.REFCAMPAIGN_WEBHOOK_SECRET,
    payload: req.body.toString('utf8'),
    header: req.header(WEBHOOK_SIGNATURE_HEADER) ?? '',
  })
  if (!ok) return res.status(400).send('invalid signature')

  const event = JSON.parse(req.body.toString('utf8'))
  switch (event.event) {
    case 'conversion.created':
      // …
      break
  }
  res.sendStatus(200)
})
```

On other stacks, recompute the HMAC yourself:

```python
import hmac, hashlib, time

def verify(secret: str, raw_body: str, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split('=', 1) for p in header.split(','))
    ts, sig = int(parts['t']), parts['v1']
    if abs(time.time() - ts) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f'{ts}.{raw_body}'.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)
```

## Retries and idempotency

* Respond with a **2xx** status as soon as you've accepted the event. Any non-2xx (or a timeout past 10s) is treated as a failure.
* Failed deliveries are retried with exponential backoff (up to 5 attempts).
* Deliveries are **idempotent on `idempotencyKey`** — a retry never produces a second successful delivery, and your handler should treat a repeated key as a no-op.
* Keep your handler fast: do the heavy work asynchronously and return 2xx immediately.

Every delivery (and its response status) is recorded in **Settings → Webhooks → View deliveries** for audit and debugging.

## Security

* Serve your endpoint over HTTPS only.
* Store the signing secret in a secret manager — never in source code.
* Verify the signature on every request, on the raw body, with a constant-time comparison.
* Rotate the secret if it leaks — the previous secret stops validating immediately.
