# Guide to FirstPromoter Webhooks: Features and Best Practices

FirstPromoter webhooks tell your systems what is happening in your affiliate program: a promoter signs up, a referral converts into a paying customer, a commission is approved, a payout completes. If you sync affiliates into a CRM, grant rewards inside your product, or reconcile payouts with your finance tools, webhooks deliver those changes without polling FirstPromoter's API.

This guide covers FirstPromoter's Webhooks v2 subscriptions, the events and payload, how to authenticate requests that FirstPromoter doesn't sign, and the practices that keep a receiver reliable.

## What are FirstPromoter webhooks?

FirstPromoter has two webhook systems. Legacy webhooks give each campaign three fixed URLs. Webhooks v2, which FirstPromoter calls "a complete rewrite", lets you create any number of subscriptions per account, each with its own URL, event types, campaign filter and custom headers. This guide is about v2.

Each matching event creates a delivery record, and FirstPromoter sends a JSON `POST` to the subscription's URL. Requests are not signed. The documented way to authenticate them is a custom header: FirstPromoter describes subscription headers as "useful for API keys or authorization tokens your endpoint requires", and its example is `Authorization: Bearer my-token`. Legacy webhooks don't support custom headers, so they have nothing your receiver can check.

## FirstPromoter webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Settings > Integrations > Webhooks > Add new webhook** |
| Subscriptions | Unlimited per account (legacy: 3 URLs per campaign) |
| Event selection | Exact `event_types` strings, plus optional `campaign_ids` |
| Method | `POST`, `Content-Type: application/json` |
| Delivery headers | `X-Event-Id`, `X-Event-Type`, plus your custom headers |
| Authentication | Custom headers on the subscription (v2 only) |
| Signature | None |
| Success | Any `2xx` |
| Timeout | 30 seconds by default (legacy: 15 seconds) |
| Retries | Exponential backoff from about 2 minutes, up to `max_retries` (see below) |
| Delivery history | Per subscription in the dashboard, with manual **Retry** and **Send test** |
| Source IPs | Not published |

## Common events

Event types follow `{entity}.{action}`. FirstPromoter documents 45 of them:

| Area | Events |
| --- | --- |
| Promoters | `promoter.created`, `promoter.updated`, `promoter.deleted`, `promoter.balance.updated` |
| Promoter campaigns | `promoter_campaign.created`, `.updated`, `.deleted`, `.accepted`, `.pending`, `.rejected`, `.blocked`, `.inactive` |
| Referrals | `referral.created`, `referral.updated`, `referral.deleted`, `referral.converted`, `referral.cancelled`, `referral.moved` |
| Commissions | `commission.created`, `.updated`, `.deleted`, `.approved`, `.pending`, `.denied` |
| Payouts | `payout.created`, `.updated`, `.deleted`, `.pending`, `.processing`, `.completed`, `.failed`, `.cancelled`, `payout.commissions.added`, `payout.commissions.removed` |
| Payout methods | `payout_method.created`, `.updated`, `.deleted` |
| Payments batches | `payments_batch.created`, `.updated`, `.deleted`, `.initiated`, `.processing`, `.completed`, `.failed` |
| Contract documents | `contract_document.signed` |

`referral.moved` only fires when an affiliate crosses a campaign upgrade or downgrade threshold and the campaign has referral moving turned on.

Every event has the same top-level shape. Here is FirstPromoter's `referral.updated` example:

```json
{
  "event_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
  "event_type": "referral.updated",
  "action": "updated",
  "entity_type": "referral",
  "entity_id": 55678,
  "changes": {
    "state": ["signup", "active"],
    "customer_since": [null, "2025-01-20T14:00:00Z"]
  },
  "data": {
    "id": 55678,
    "state": "active",
    "email": "user@example.com",
    "uid": "user_ext_id",
    "customer_since": "2025-01-20T14:00:00Z",
    "fraud_check": "no_suspicion",
    "created_at": "2025-01-18T09:00:00Z"
  },
  "timestamp": "2025-01-20T14:00:05Z"
}

```

`action` is always `created`, `updated` or `deleted`, even when the event type is more specific: `contract_document.signed` arrives with `action: "updated"`. For updated events, `changes` maps each changed field to `[old_value, new_value]`. For created and deleted events it is empty and the full resource is in `data`. `event_id` is a UUID that FirstPromoter tells you to use for deduplication, and it is repeated in the `X-Event-Id` header.

## Setting up FirstPromoter webhooks

Pick a header and a long random value first. A bearer token in `Authorization` matches FirstPromoter's own example, or use a dedicated header such as `X-Webhook-Secret`.

1. In FirstPromoter, go to **Settings > Integrations > Webhooks** and click **Add new webhook**.
2. Enter your HTTPS endpoint URL.
3. Choose the event types. Leave the campaign filter at all campaigns, or pick specific campaigns.
4. Add a custom header with your name and value, for example `{"Authorization": "Bearer your-long-random-token"}`.
5. Save, then use **Send test** to post a test payload, which has `"data": { "test": true }`.

**With Hookdeck.** FirstPromoter is a supported source in Hookdeck. Create a source with the FirstPromoter type, enter the custom header's name as the **API Key Header** and its value as the **API Key**, exactly as you set it on the subscription, including any `Bearer ` prefix. Then use the Hookdeck source URL as the subscription URL. Hookdeck's setup note says the source covers Webhooks v2 only, because legacy webhooks can't send custom headers. The source accepts `POST` only, which is the method FirstPromoter uses. A request without the right header still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

## Securing FirstPromoter webhooks

Compare the custom header with the stored value in constant time. Look the header up case-insensitively, compare the whole value including any prefix, and check lengths before calling `crypto.timingSafeEqual`.

```javascript
const crypto = require('crypto');
const express = require('express');

function safeEqual(a, b) {
  const x = Buffer.from(String(a), 'utf8');
  const y = Buffer.from(String(b), 'utf8');
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function verifyFirstPromoterRequest(headers) {
  const expected = process.env.FIRSTPROMOTER_WEBHOOK_SECRET; // e.g. "Bearer <token>"
  if (!expected) return false; // fail closed when unconfigured
  const name = (process.env.FIRSTPROMOTER_WEBHOOK_HEADER || 'Authorization').toLowerCase();
  const received = headers[name];
  return typeof received === 'string' && safeEqual(received, expected);
}

const app = express();

app.post('/webhooks/firstpromoter', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyFirstPromoterRequest(req.headers)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  // Deduplicate on event.event_id, route on event.event_type, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

def verify_firstpromoter_request(headers) -> bool:
    expected = os.environ.get("FIRSTPROMOTER_WEBHOOK_SECRET")
    if not expected:
        return False
    name = os.environ.get("FIRSTPROMOTER_WEBHOOK_HEADER", "Authorization")
    received = headers.get(name)  # case-insensitive in Starlette and Flask
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))

```

A static header proves the sender knows the value, but it doesn't prove the body is unchanged, and it gives no replay protection: nothing is signed, and the `timestamp` in the body is not covered by anything. Serve the endpoint over HTTPS, keep the value long and random, keep it out of logs, and replace it on the subscription if it might have leaked. For anything that moves money, such as `payout.completed`, confirm the object through the FirstPromoter API before acting.

> **Make FirstPromoter webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your header on every FirstPromoter request, stores each referral, commission and payout event, and retries delivery to your service on your own schedule.

## FirstPromoter webhook limitations and pain points

### Unclear retry defaults

**The Problem:** You can't be sure how long FirstPromoter keeps retrying before a delivery is marked `failed`.

**Why It Happens:** The Webhooks v2 overview lists "3 retries by default", while the delivery page says retries stop when failed attempts reach the subscription's `max_retries`, "default: `10`, configurable from `0`–`10`", spanning roughly 17 hours. The backoff starts at about 2 minutes and roughly doubles.

**Workarounds:**

* Check the `max_retries` value on your subscriptions rather than relying on a default.
* Watch the delivery history for `retrying` and `failed` deliveries.
* After an outage, fix the cause and retry failed deliveries from the dashboard.

**How Hookdeck Can Help:** Hookdeck acknowledges each request as it arrives and stores it, then retries delivery to your service with [automatic retries](/docs/retries) that you configure. [Issues](/docs/issues) alert you when deliveries fail.

### A `failed` delivery can't be retried as-is

**The Problem:** You try to retry an old failed delivery and FirstPromoter won't send it.

**Why It Happens:** Manual retries need retry budget: the delivery's `attempts` must be below the subscription's `max_retries`. A `failed` delivery has used it all, so FirstPromoter says to increase `max_retries` on the subscription first.

**Workarounds:**

* Raise `max_retries`, retry the delivery, then set it back if you need to.
* For bulk recovery, read the current state of promoters, referrals and commissions from the API.

**How Hookdeck Can Help:** Events Hookdeck has received can be replayed to your service, individually or in bulk, without touching the FirstPromoter subscription.

### No signature, and legacy webhooks have nothing to check

**The Problem:** A legacy webhook URL accepts anything that looks like a FirstPromoter request.

**Why It Happens:** Neither system signs requests, and only v2 can send custom headers.

**Workarounds:**

* Move integrations to Webhooks v2 and add a custom header to every subscription.
* Treat payloads as notifications and confirm money-related changes through the API.

**How Hookdeck Can Help:** Hookdeck's FirstPromoter source compares the header you name against the exact value you enter, and logs every request with its headers and body.

### Duplicates from retries

**The Problem:** The same commission or payout event is processed twice.

**Why It Happens:** Retries and manual retries resend the event, and a handler that times out after doing the work causes a retry anyway.

**Workarounds:**

* Store `event_id` (or the `X-Event-Id` header) before processing, and skip repeats.

**How Hookdeck Can Help:** Hookdeck [deduplication](/docs/deduplication) can drop repeated events based on `event_id` before they reach your handler.

## Best practices

### Subscribe narrowly

Choose the event types each consumer needs and filter by campaign where it helps. Separate subscriptions can carry separate secrets, which limits what one leaked value exposes.

### Use `changes` for updated events

`changes` tells you exactly which fields moved and from what. A `commission.updated` with `"status": ["pending", "approved"]` is enough to act on without diffing `data` yourself.

### Route on `event_type`, not `action`

`action` is coarse. Several specific events, such as `commission.approved` or `contract_document.signed`, share `updated`. Switch on `event_type` and answer `200` to types you don't handle.

### Acknowledge fast and process asynchronously

Check the header, store the event, return `200`, then do the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

FirstPromoter Webhooks v2 sends unsigned JSON `POST` requests with `X-Event-Id` and `X-Event-Type` headers and any custom headers you configure. Add a secret header to every subscription and compare it in constant time over HTTPS, deduplicate on `event_id`, route on `event_type`, and check each subscription's `max_retries` because the documented default is inconsistent.

[Hookdeck Event Gateway](https://hookdeck.com) checks the header on every FirstPromoter request, stores each event, and retries delivery so a commission or payout change isn't lost to an outage on your side.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle FirstPromoter webhooks reliably in minutes.