Webhooks
GetAff envía un POST a su punto de conexión HTTPS cuando ocurre algo en su cuenta. Cada entrega va firmada; su código la verifica con un HMAC y nunca tiene que consultar.
Eventos
Cuatro eventos, cada uno un cuerpo JSON { id, type, created_at, data }. data lleva identificadores y recuentos, nunca una dirección de correo ni un mensaje: lea el objeto completo a través de la API cuando lo necesite. id empieza por msg_ y es el mismo en cada reintento del mismo evento.
search.finished- Una búsqueda de socios de uno de sus sitios terminó — desde la pantalla, el chat, la API o MCP.
data:siteId,searchId,results,onTopic,sources(por fuente:id,status,count),startedAt,finishedAt. prospect.replied- Un prospecto respondió a una secuencia de contacto (detectado en su buzón por el ciclo de diez minutos).
data:prospectId,sequenceId,mailboxEmail,repliedAt. Una etapa movida a mano no lo dispara. application.received- Un creador solicitó unirse a uno de sus programas.
data:applicationId,programId,programSlug,programName,affiliateName,affiliateSlug,score,autoApproved. partner.approved- Una colaboración en uno de sus programas pasó a aprobada — su decisión, su regla de aprobación automática o una invitación aceptada.
data:partnershipId,programId,programSlug,programName,affiliateName,origin.
Registrar un punto de conexión
Desde Ajustes de la cuenta › API › Webhooks, o con la API: POST /v1/webhooks con el ámbito write. La respuesta lleva el secreto de firma (whsec_…) UNA SOLA VEZ; guárdelo en el servidor. Hasta diez puntos de conexión por cuenta, cada uno con sus eventos y su secreto. La URL debe ser https:// en un host público: una dirección privada, de bucle local o de enlace local, localhost o un nombre .internal se rechaza al registrar y de nuevo en cada entrega.
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.Lo que llega
Un POST con tres cabeceras — webhook-id (el identificador del mensaje), webhook-timestamp (segundos Unix) y webhook-signature (v1, seguido del HMAC-SHA256 en base64 de {id}.{timestamp}.{body}, con los bytes detrás de whsec_ como clave) — y el cuerpo JSON. Responda cualquier 2xx en menos de 10 segundos; el cuerpo de su respuesta se ignora. Cualquier otro estado, un tiempo agotado o un error de conexión cuenta como intento fallido.
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"}}Verificar una entrega
Es el esquema Standard Webhooks: cualquier biblioteca con ese nombre verifica una entrega de GetAff solo con el secreto. Sin biblioteca, cuatro pasos:
- Lea el cuerpo CRUDO de la petición tal como llegó — no un objeto reserializado: una clave reordenada cambia los bytes y la firma deja de coincidir.
- Rechace un
webhook-timestampa más de cinco minutos de su reloj: es la ventana contra repeticiones. - Calcule el HMAC-SHA256 de
{webhook-id}.{webhook-timestamp}.{body}con el secreto decodificado de base64, codifíquelo en base64 y compárelo en tiempo constante con cada valorv1,…separado por espacio dewebhook-signature. Basta una coincidencia (durante una rotación de secreto viajan dos valores). - Trate
idcomo clave de idempotencia: un reintento lleva el mismo, y un receptor que guarda los identificadores vistos procesa cada evento una vez.
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 wrongReintentos y la cola de fallidos
Un intento fallido se repite tras 1 minuto, luego 5 minutos, 30 minutos, 2 horas y 12 horas — seis intentos en unas quince horas — siempre con el mismo webhook-id y el mismo cuerpo. Tras el sexto, la entrega pasa a fallida definitiva: conservada y visible (Ajustes de la cuenta, o GET /v1/webhooks/{id}), nunca reintentada. Un host rechazado o un 404/410 de su parte es definitivo de inmediato. Las entregas siguen visibles treinta días. La acción «Probar» (o POST /v1/webhooks/{id}/test) envía al momento un evento webhook.test firmado y muestra la respuesta de su punto de conexión.
Rotar un secreto
Ajustes de la cuenta › Rotar, o POST /v1/webhooks/{id}/rotate. Durante las 24 horas siguientes cada entrega lleva dos firmas en webhook-signature — nueva y antigua, separadas por espacio — para que un receptor actualizado más tarde ese día siga verificando. Después, el secreto antiguo desaparece.
- Un secreto se muestra una vez y se sella en reposo; nadie en GetAff puede volver a leerlo. ¿Lo perdió? Rótelo.
- Las entregas salen de los servidores de GetAff, nunca de un navegador, y nunca llevan su clave de API.
- Un punto de conexión puede pausarse (
disabled) sin perder su secreto; en pausa no recibe nada y sus eventos no se encolan. - Verifique cada entrega antes de actuar. Una petición sin firma o mal firmada a su punto de conexión no vino de GetAff.
- Responda
2xxrápido y trabaje después: un manejador lento es un tiempo agotado, y un tiempo agotado es un reintento que recibirá dos veces.