GetAff
Sign inList my program
API documentation

Webhooks

GetAff POSTs to your HTTPS endpoint when something happens on your account. Every delivery is signed; your code verifies it with one HMAC and never has to poll.

Events

Four events, each a JSON body { id, type, created_at, data }. data carries identifiers and counts, never an e-mail address or a message: read the full object through the API when you need it. id starts with msg_ and is the same on every retry of the same event.

search.finished
A partner search of one of your sites finished — from the screen, the chat, the API or MCP. data: siteId, searchId, results, onTopic, sources (per source: id, status, count), 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 programmes. data: applicationId, programId, programSlug, programName, affiliateName, affiliateSlug, score, autoApproved.
partner.approved
A partnership on one of your programmes became approved — your decision, your auto-approval rule or an accepted invite. data: partnershipId, programId, programSlug, programName, affiliateName, origin.

Register an endpoint

From Account settings › API › Webhooks, or with the API: POST /v1/webhooks with the write scope. The answer carries the signing secret (whsec_…) ONCE; store it server-side. Up to ten endpoints per account, each with its own events and secret. The URL must be https:// on a public host: a private, loopback or link-local address, localhost or an .internal name is refused at registration and again at every delivery.

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

# → { "data": { "endpoint": { "id": "whe_…", … }, "secret": "whsec_…" } }
#   The secret is in THIS response only. Store it server-side.
Webhooks · API keys

What arrives

A POST with three headers — webhook-id (the message 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_) — and the JSON body. Answer any 2xx within 10 seconds; the body of your answer is ignored. Anything else, a timeout or a connection error counts as a failed attempt.

HTTP
POST /getaff HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
webhook-id: msg_3f1c2b9a7d6e5f4c3b2a19087f6e5d4c
webhook-timestamp: 1762337524
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pfCnzRIJnS2bzE=

{"id":"msg_3f1c2b9a7d6e5f4c3b2a19087f6e5d4c","type":"prospect.replied",
 "created_at":"2026-11-05T10:12:00.000Z",
 "data":{"prospectId":"…","sequenceId":"…","mailboxEmail":"[email protected]",
         "repliedAt":"2026-11-05T10:11:58.000Z"}}

Verify a delivery

This is the Standard Webhooks scheme: any library of that name verifies a GetAff delivery with the secret alone. Without a library, four steps:

  1. Read the RAW request body as received — not a re-serialised object: one reordered key changes the bytes and the signature no longer matches.
  2. Refuse a webhook-timestamp more than five minutes away from your clock: that is the replay window.
  3. Compute HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body} with the base64-decoded secret, base64-encode it, and compare in constant time with each space-separated v1,… value of webhook-signature. One match is enough (two values travel during a secret rotation).
  4. Treat id as an idempotency key: a retry carries the same one, so a receiver that stores seen ids processes each event once.
Node.js — no dependency
import { createHmac, timingSafeEqual } from 'node:crypto';

// Express: app.post("/getaff", express.raw({ type: "application/json" }), handler)
// The body MUST be the raw bytes — never a parsed-then-stringified object.
export function verifyGetAffWebhook(headers, rawBody, secret, toleranceSec = 300) {
  const id = headers['webhook-id'];
  const ts = headers['webhook-timestamp'];
  const sig = headers['webhook-signature'];
  if (!id || !ts || !sig) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false; // replay window

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest();
  return sig.split(' ').some((part) => {
    const [version, value] = part.split(',');
    if (version !== 'v1' || !value) return false;
    const given = Buffer.from(value, 'base64');
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

// Then: const event = JSON.parse(rawBody); dedupe on event.id; answer 200 at once.
Node.js — standardwebhooks
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.GETAFF_WEBHOOK_SECRET.replace(/^whsec_/, ''));
const event = wh.verify(rawBody, headers); // throws when the signature or timestamp is wrong

Retries and the dead letter

A failed attempt is retried after 1 minute, then 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 (Account settings, or GET /v1/webhooks/{id}), never retried. A refused host or a 404/410 from your side is final at once. Deliveries stay visible for thirty days. The "Test" action (or POST /v1/webhooks/{id}/test) sends a signed webhook.test event right away and shows your endpoint’s answer.

Rotate a secret

Account settings › Rotate, or POST /v1/webhooks/{id}/rotate. For the next 24 hours each delivery carries two signatures in webhook-signature — new and old, space-separated — so a receiver you update later today still verifies. After that, the old secret is gone.

What to expect from us, and from you
  • A secret is shown once and sealed at rest; nobody at GetAff can read it back. Lost it? Rotate.
  • Deliveries go out from GetAff’s own servers, never from a browser, and never carry your API key.
  • An endpoint can be paused (disabled) without losing its secret; a paused endpoint receives nothing and its events are not queued.
  • Verify every delivery before acting on it. An unsigned or mis-signed request to your endpoint did not come from GetAff.
  • Return 2xx fast and do the work afterwards: a slow handler is a timeout, and a timeout is a retry you will receive twice.

Reference