Gareth Wilson Gareth Wilson

Guide to FirstPromoter Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationSettings > Integrations > Webhooks > Add new webhook
SubscriptionsUnlimited per account (legacy: 3 URLs per campaign)
Event selectionExact event_types strings, plus optional campaign_ids
MethodPOST, Content-Type: application/json
Delivery headersX-Event-Id, X-Event-Type, plus your custom headers
AuthenticationCustom headers on the subscription (v2 only)
SignatureNone
SuccessAny 2xx
Timeout30 seconds by default (legacy: 15 seconds)
RetriesExponential backoff from about 2 minutes, up to max_retries (see below)
Delivery historyPer subscription in the dashboard, with manual Retry and Send test
Source IPsNot published

Common events

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

AreaEvents
Promoterspromoter.created, promoter.updated, promoter.deleted, promoter.balance.updated
Promoter campaignspromoter_campaign.created, .updated, .deleted, .accepted, .pending, .rejected, .blocked, .inactive
Referralsreferral.created, referral.updated, referral.deleted, referral.converted, referral.cancelled, referral.moved
Commissionscommission.created, .updated, .deleted, .approved, .pending, .denied
Payoutspayout.created, .updated, .deleted, .pending, .processing, .completed, .failed, .cancelled, payout.commissions.added, payout.commissions.removed
Payout methodspayout_method.created, .updated, .deleted
Payments batchespayments_batch.created, .updated, .deleted, .initiated, .processing, .completed, .failed
Contract documentscontract_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:

{
  "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.

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:

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 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 that you configure. 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 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 and our guide to 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 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 for free and handle FirstPromoter webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.