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 -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.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.
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:
- Read the RAW request body as received — not a re-serialised object: one reordered key changes the bytes and the signature no longer matches.
- Refuse a
webhook-timestampmore than five minutes away from your clock: that is the replay window. - 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-separatedv1,…value ofwebhook-signature. One match is enough (two values travel during a secret rotation). - Treat
idas an idempotency key: a retry carries the same one, so a receiver that stores seen ids processes each event once.
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.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 wrongRetries 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.
- 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
2xxfast and do the work afterwards: a slow handler is a timeout, and a timeout is a retry you will receive twice.