# FirstPromoter Webhooks

FirstPromoter (affiliate and referral management) Webhooks v2 sends a JSON `POST` when something
happens in your account: a promoter signs up, a referral converts, a commission is approved, a
payout completes. Requests are **not signed**. You authenticate them with a secret header that
you add to the webhook subscription yourself, and your endpoint compares that header with the
value you stored.

Covers Webhooks v2 only: add a custom header to the subscription and enter it here. Legacy webhooks cannot send custom headers.

## When to Use This Skill

* How do I receive FirstPromoter webhooks?
* How do I authenticate a FirstPromoter webhook if it has no signature?
* Which header should my endpoint check? (The one you choose on the subscription)
* How do I handle `referral.converted`, `commission.approved` or `payout.completed` events?
* Why does my FirstPromoter webhook endpoint return 401?

## Verification (core)

The header name is **user-chosen** (default here: `Authorization`) and the expected value is the
**exact, whole header value**, including any `Bearer ` prefix you typed. Look the header up
case-insensitively and compare the whole value; don't strip or parse a prefix.

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

function safeEqual(a, b) {
  const ha = crypto.createHash('sha256').update(String(a)).digest(); // equal-length buffers
  const hb = crypto.createHash('sha256').update(String(b)).digest();
  return crypto.timingSafeEqual(ha, hb);
}

function verifyFirstPromoterRequest(headers, secret = process.env.FIRSTPROMOTER_WEBHOOK_SECRET,
    name = process.env.FIRSTPROMOTER_WEBHOOK_HEADER || 'Authorization') {
  if (!secret) return false;                    // never accept when unconfigured
  const received = headers[name.toLowerCase()]; // Node lowercases header names
  return Boolean(received) && safeEqual(received, secret);
}

```

* Missing header or mismatch: `401`. Bad JSON: `400`. Handled: `200` (any `2xx` acknowledges).
* Read the body raw (`express.raw({ type: '*/*' })`) so any content type works, authenticate,
  then parse.
* In Python use `hmac.compare_digest` on **bytes** (encode both sides; `str` raises on non-ASCII).

**A static shared credential authenticates the sender, not the body, and gives no replay
protection** (no signature, no timestamp). Use HTTPS, and rotate the credential if it leaks.
Deduplicate on `event_id` instead of relying on freshness.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/firstpromoter-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/firstpromoter-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/firstpromoter-webhooks/examples/fastapi/).

## Event Types

Body: `{event_id, event_type, action, entity_type, entity_id, changes, data, timestamp}`. The
`X-Event-Id` and `X-Event-Type` headers repeat `event_id` and `event_type`. Dispatch on
`event_type`; dedupe on `event_id`.

| Event | Sent When |
| --- | --- |
| `promoter.created` | A promoter signed up or was created via the API |
| `promoter.updated` | A promoter's account details changed |
| `promoter.deleted` | A promoter was deleted |
| `promoter.balance.updated` | A promoter's earnings, current or paid balance changed |
| `referral.created` | A new referral (lead or customer) was tracked |
| `referral.updated` | A referral's details changed (state, plan, cancellation) |
| `referral.converted` | A referral (lead) became a customer |
| `referral.cancelled` | A referral was cancelled |
| `referral.deleted` | A referral was deleted |
| `referral.moved` | A referral moved to another campaign |
| `commission.created` | A commission was created |
| `commission.updated` | A commission changed (`changes` lists the fields) |
| `commission.approved` | A commission was approved |
| `commission.pending` | A commission was set to pending |
| `commission.denied` | A commission was denied |
| `commission.deleted` | A commission was deleted |
| `payout.created` | A payout was created |
| `payout.completed` | A payout completed successfully |
| `payout.failed` | A payout failed |
| `contract_document.signed` | A promoter signed a contract document |

More types exist (`promoter_campaign.*`, `payout_method.*`, `payments_batch.*`, other `payout.*`);
see [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/firstpromoter-webhooks/references/overview.md).

## Environment Variables

```bash
FIRSTPROMOTER_WEBHOOK_HEADER=Authorization        # header name you added to the subscription
FIRSTPROMOTER_WEBHOOK_SECRET=Bearer your-secret   # exact, whole header value

```

## Local Development

FirstPromoter can't reach `localhost`. Tunnel with the Hookdeck CLI (no account required):

```bash
npx hookdeck-cli listen 3000 firstpromoter --path /webhooks/firstpromoter

```

Use the printed URL as the subscription URL in FirstPromoter.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/firstpromoter-webhooks/references/overview.md) - What the webhooks are, events, payload and delivery
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/firstpromoter-webhooks/references/setup.md) - Add a Webhooks v2 subscription with a secret header
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/firstpromoter-webhooks/references/verification.md) - Header check details, gotchas, debugging 401s
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/firstpromoter-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests