GetAff
Sign inList my program
Article

Connecting GetAff to your CRM or Slack with signed webhooks

A reply from a creator is worth acting on within the hour, not at the next weekly review. Webhooks let GetAff tell your systems the moment something happens — a search finished, a prospect replied, a creator applied, a partnership was approved — so your CRM gets the update and your team gets the Slack message without anyone polling.

This article covers the four events, registering an endpoint, verifying the signature in Node and Python, the retry schedule, and two concrete integrations.

Updated

The four events

Every delivery is a JSON body { id, type, created_at, data }. data carries identifiers and counts, never an e-mail address or a message: when you need the full object, read it through the API with the identifier.

  • search.finished — a partner search of one of your sites finished, whether it ran from the screen, the chat, the API or MCP. data: siteId, searchId, results, onTopic, per-source sources, startedAt, finishedAt.
  • prospect.replied — a prospect answered an outreach sequence, detected in your mailbox by the ten-minute tick. data: prospectId, sequenceId, mailboxEmail, repliedAt. A stage moved by hand does not fire it.
  • application.received — a creator applied to one of your programs. data includes applicationId, programSlug, affiliateName, score and autoApproved.
  • partner.approved — a partnership became approved: your decision, your auto-approval rule or an accepted invite. data includes partnershipId, programSlug, affiliateName and origin.

Register an endpoint

From Account settings › API › Webhooks, or with a key carrying the write scope:

curl -s -X POST https://www.getaff.org/api/v1/webhooks \
  -H "Authorization: Bearer $GETAFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.example.com/getaff",
       "events": ["prospect.replied", "application.received"],
       "description": "CRM sync"}'

The answer carries the signing secret (whsec_…) once: store it server-side next to your other secrets. An account holds up to ten endpoints, each with its own events and its own secret. The URL must be https:// on a public host; private, loopback and link-local addresses are refused at registration and again at every delivery.

Verify every delivery

Deliveries follow the Standard Webhooks specification. Three headers travel with the body: webhook-id, webhook-timestamp (Unix seconds) and webhook-signature — v1, followed by the base64 HMAC-SHA256 of {id}.{timestamp}.{body}, keyed with the bytes behind whsec_. Any Standard Webhooks library verifies it with the secret alone; without one, it takes a dozen lines.

  1. Read the RAW body as received. A re-serialised object changes the bytes and the signature no longer matches.
  2. Refuse a timestamp more than five minutes from your clock: that is the replay window.
  3. Compute the HMAC and compare it in constant time with each space-separated v1,… value. One match is enough — two values travel during a secret rotation.
  4. Store the webhook-id you processed: a retry carries the same one.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const ts = Number(headers['webhook-timestamp']);
  const sigs = (headers['webhook-signature'] ?? '').split(' ');
  if (!id || !ts || Math.abs(Date.now() / 1000 - ts) > 300) return false;
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = Buffer.from(
    'v1,' + createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64'),
  );
  return sigs.some((s) => {
    const got = Buffer.from(s);
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}
Python
import base64, hashlib, hmac, time

def verify(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("webhook-id", "")
    ts = headers.get("webhook-timestamp", "")
    if not msg_id or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{ts}.".encode() + raw_body
    expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(hmac.compare_digest(s, expected)
               for s in headers.get("webhook-signature", "").split(" "))

An unsigned or mis-signed request to your endpoint did not come from GetAff. Deliveries never carry your API key.

Answer fast, expect retries

Answer any 2xx within ten seconds; the body of your answer is ignored. Anything else — another status, a timeout, a connection error — is a failed attempt, retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours: six attempts over about fifteen hours, always with the same webhook-id and body. After the sixth, the delivery is dead-lettered: kept and visible in Account settings or through GET /v1/webhooks/{id}, never retried. A 404 or 410 from your side is final at once.

  • Acknowledge first, work afterwards: push the event to a queue and return 200.
  • Deduplicate on webhook-id, so a retry after a slow answer is processed once.
  • Use the “Test” action, or POST /v1/webhooks/{id}/test, to send a signed webhook.test event and see your endpoint’s answer.

Example: keep your CRM in sync

Subscribe one endpoint to prospect.replied and partner.approved. On a reply, fetch the prospect with GET /v1/prospects/{prospectId} (status, contact channel, provenance), then create or update the contact in your CRM and open a task for the account owner. On an approval, move the deal to “partner” and store the partnershipId, so finance can later match commissions to it.

Because data holds identifiers rather than personal data, the event itself is safe to log; the details travel only through your authenticated API call.

Example: a Slack message for every application

Slack’s incoming webhooks accept a simple JSON post. After verifying the signature, forward a short summary of application.received:

Forward to Slack
if (event.type === 'application.received') {
  const d = event.data;
  await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `New application to ${d.programName} from ${d.affiliateName} (score ${d.score})`,
    }),
  });
}

search.finished makes a good digest too: post the number of on-topic results and a link to the Find page of the site, so the team reviews a new search the same day.

One endpoint per job

Each endpoint has its own events and its own secret, so split them by purpose rather than sending everything to one URL. A CRM sync that only needs prospect.replied and partner.approved should not receive search.finished; a Slack bridge for the partnerships team may only need application.received. Smaller handlers are easier to verify, to monitor and to pause.

Each endpoint also shows the status of its last delivery and its last twenty deliveries, with the answer your server gave. When a receiver breaks, that history tells you whether it is your code, your certificate or your firewall — and the dead letter keeps every event you missed, for thirty days, so nothing is lost while you fix it.

Rotate a secret without downtime

Rotate from Account settings or with POST /v1/webhooks/{id}/rotate. For the next 24 hours each delivery carries two signatures — new and old — so a receiver you update later in the day still verifies. An endpoint can also be paused without losing its secret; while paused it receives nothing and nothing is queued. The webhooks documentation has the full contract, and the reference every endpoint.

Documentation

The exact contract, generated from the schemas the routes enforce:

Read next