Agent skill

FirstPromoter Webhooks Skill

Receive and authenticate FirstPromoter Webhooks v2. Use when setting up a FirstPromoter webhook endpoint, checking the custom secret header you add to the subscription, debugging 401s, or handling affiliate and referral events like promoter.created, referral.converted, commission.approved and payout.completed.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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.

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/, examples/nextjs/, 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.

EventSent When
promoter.createdA promoter signed up or was created via the API
promoter.updatedA promoter's account details changed
promoter.deletedA promoter was deleted
promoter.balance.updatedA promoter's earnings, current or paid balance changed
referral.createdA new referral (lead or customer) was tracked
referral.updatedA referral's details changed (state, plan, cancellation)
referral.convertedA referral (lead) became a customer
referral.cancelledA referral was cancelled
referral.deletedA referral was deleted
referral.movedA referral moved to another campaign
commission.createdA commission was created
commission.updatedA commission changed (changes lists the fields)
commission.approvedA commission was approved
commission.pendingA commission was set to pending
commission.deniedA commission was denied
commission.deletedA commission was deleted
payout.createdA payout was created
payout.completedA payout completed successfully
payout.failedA payout failed
contract_document.signedA promoter signed a contract document

More types exist (promoter_campaign.*, payout_method.*, payments_batch.*, other payout.*); see references/overview.md.

Environment Variables

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):

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

Use the printed URL as the subscription URL in FirstPromoter.

Resources


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 10, 2026

View on GitHub →