GetAff
Documentación de la API

Inicio rápido: de la clave al socio contactado

Todo el recorrido de captación de socios con la API REST, en unos diez minutos: ocho llamadas, cada una en curl, Node.js y Python, con la forma de la respuesta. Solo un paso gasta créditos, y antes pregunta.

Antes de empezar

  • Una cuenta de GetAff con al menos un sitio. Toda búsqueda de socios se refiere a un sitio, y los sitios se crean en la aplicación.
  • Una clave de API con los ámbitos read, search, write y spend, creada desde la página «Claves de API». Sin spend, todo funciona salvo el paso 7, que es de pago.
  • El plan Pro para el paso 7: las búsquedas de correo de pago (como las búsquedas en directo) no están incluidas en el plan gratuito, que responde 403 plan_required.
  • Para el paso 8, una sola vez en la aplicación: un buzón conectado, unos ajustes del remitente completos (nombre, empresa, dirección postal) y un programa publicado.
Claves de API

Preparación

Guarde la clave en una variable de entorno, en un servidor; nunca en un navegador. Los ejemplos de Node.js y Python usan la pequeña función siguiente: envía la clave, codifica el cuerpo en JSON, añade Idempotency-Key cuando se le da una y lanza una excepción ante cualquier error salvo el 402, que trata el paso 7. Los errores son application/problem+json: decida según error.code, nunca según el mensaje.

curl
export GETAFF_API_KEY='gaff_live_…'   # scopes: read search write spend
BASE=https://www.getaff.org/api
SITE=Wq3b9ZK1                         # the site id from step 1
Node.js
// Node.js 18+: fetch and crypto.randomUUID are built in.
const BASE = 'https://www.getaff.org/api';

async function getaff(method, path, { body, idempotencyKey } = {}) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.GETAFF_API_KEY}`,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
      ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json();
  // Errors are application/problem+json: branch on error.code, never on the message.
  if (!res.ok && res.status !== 402) {
    throw new Error(`${res.status} ${json.error.code}: ${json.detail}`);
  }
  return { status: res.status, json };
}
Python (requests)
# pip install requests
import os
import uuid

import requests

BASE = "https://www.getaff.org/api"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['GETAFF_API_KEY']}"


def getaff(method, path, body=None, idempotency_key=None):
    headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
    r = session.request(method, BASE + path, json=body, headers=headers, timeout=90)
    payload = r.json()
    # Errors are application/problem+json: branch on error.code, never on the message.
    if not r.ok and r.status_code != 402:
        raise RuntimeError(f"{r.status_code} {payload['error']['code']}: {payload['detail']}")
    return r.status_code, payload
Idempotencia, en cada escritura

Cada escritura de esta guía acepta una cabecera Idempotency-Key (de 8 a 128 caracteres entre A-Z a-z 0-9 _ - : .; un UUID sirve). Un reintento con la misma clave y el mismo cuerpo en las 24 horas siguientes reproduce el primer éxito con Idempotent-Replayed: true en lugar de ejecutarse de nuevo; la misma clave con otro cuerpo responde 409 idempotency_conflict. Los errores no se guardan: una petición corregida puede reutilizar su clave. Una clave por operación, reutilizada en sus reintentos.

1. Listar sus sitios

GET /v1/sites devuelve los sitios de su cuenta, cada uno con el programa de afiliación que ofrece en GetAff. Guarde el id del sitio para el que capta socios: todas las llamadas siguientes lo usan.

curl
curl -s "$BASE/v1/sites" \
  -H "Authorization: Bearer $GETAFF_API_KEY"
Node.js
const { json: sites } = await getaff('GET', '/v1/sites');
const siteId = sites.data[0].id;
Python (requests)
_, sites = getaff("GET", "/v1/sites")
site_id = sites["data"][0]["id"]
Respuesta (resumida)
{
  "data": [
    {
      "id": "Wq3b9ZK1",
      "host": "nyna.ai",
      "url": "https://nyna.ai",
      "name": "Nyna",
      "createdAt": "2026-09-02T10:11:00.000Z",
      "programme": {
        "name": "Nyna Partners",
        "slug": "nyna-partners",
        "markets": [
          "FR",
          "BE"
        ],
        "model": "cps"
      }
    }
  ]
}

2. Fijar los criterios de búsqueda

PATCH /v1/sites/{id}/criteria (ámbito write) sustituye cada campo enviado y conserva los demás: palabras clave, nichos, plataformas, países, idiomas, franjas de audiencia, antigüedad de la última publicación. { "reset": true } vuelve a los criterios que GetAff deduce del sitio. Omita este paso para buscar con esos criterios deducidos.

curl
curl -s -X PATCH "$BASE/v1/sites/$SITE/criteria" \
  -H "Authorization: Bearer $GETAFF_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"keywords":["wedding planning","bridal"],"countries":["FR","BE"]}'
Node.js
await getaff('PATCH', `/v1/sites/${siteId}/criteria`, {
  body: { keywords: ['wedding planning', 'bridal'], countries: ['FR', 'BE'] },
  idempotencyKey: crypto.randomUUID(),
});
Python (requests)
getaff(
    "PATCH",
    f"/v1/sites/{site_id}/criteria",
    {"keywords": ["wedding planning", "bridal"], "countries": ["FR", "BE"]},
    str(uuid.uuid4()),
)
Respuesta (resumida)
{
  "data": {
    "criteria": {
      "keywords": [
        "wedding planning",
        "bridal"
      ],
      "niches": [
        "weddings"
      ],
      "platforms": [
        "instagram",
        "youtube"
      ],
      "countries": [
        "FR",
        "BE"
      ],
      "languages": [
        "fr"
      ],
      "bands": [
        "micro",
        "mid"
      ],
      "activeWithinDays": 90
    },
    "edited": true,
    "droppedNiches": []
  }
}

4. Leer los resultados clasificados

GET /v1/sites/{id}/partner-search/latest recorre la última búsqueda (se conserva 30 días; cada búsqueda queda guardada también en /partner-search/history). Cada resultado lleva su key, una puntuación de afinidad fit.pfs de 0 a 100 y fit.reasons, el motivo de su puesto. minPfs conserva solo las mejores afinidades; siga page.nextCursor para obtener más.

curl
curl -s "$BASE/v1/sites/$SITE/partner-search/latest?minPfs=60&limit=50" \
  -H "Authorization: Bearer $GETAFF_API_KEY"
Node.js
const { json: latest } = await getaff(
  'GET',
  `/v1/sites/${siteId}/partner-search/latest?minPfs=60&limit=50`,
);
for (const r of latest.data.results) {
  console.log(r.rank, r.handle, r.fit.pfs, r.fit.reasons.map((x) => x.code));
}
const keys = latest.data.results.slice(0, 10).map((r) => r.key);
Python (requests)
_, latest = getaff("GET", f"/v1/sites/{site_id}/partner-search/latest?minPfs=60&limit=50")
for r in latest["data"]["results"]:
    print(r["rank"], r["handle"], r["fit"]["pfs"], [x["code"] for x in r["fit"]["reasons"]])
keys = [r["key"] for r in latest["data"]["results"][:10]]
Respuesta (resumida)
{
  "data": {
    "total": 58,
    "results": [
      {
        "key": "instagram:1784140",
        "rank": 1,
        "platform": "instagram",
        "handle": "lea.mariage",
        "displayName": "Léa · Mariage",
        "audience": 48200,
        "country": "FR",
        "fit": {
          "pfs": 86,
          "excluded": false,
          "reasons": [
            {
              "code": "topic",
              "terms": [
                "mariage"
              ],
              "host": null
            }
          ]
        }
      }
    ]
  },
  "page": {
    "limit": 50,
    "hasMore": true,
    "nextCursor": "NTA"
  }
}

5. Preseleccionar a los mejores

POST /v1/sites/{id}/shortlist (ámbito write) añade resultados de la última búsqueda por su key, hasta 50 por llamada; la preselección admite 100 como máximo. Un creador ya preseleccionado se actualiza, no se duplica; una clave que no figura en los últimos resultados vuelve en unknownKeys y no se añade nada por ella.

curl
curl -s -X POST "$BASE/v1/sites/$SITE/shortlist" \
  -H "Authorization: Bearer $GETAFF_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"keys":["instagram:1784140"]}'
Node.js
const { json: shortlist } = await getaff('POST', `/v1/sites/${siteId}/shortlist`, {
  body: { keys },
  idempotencyKey: crypto.randomUUID(),
});
console.log(shortlist.added, shortlist.unknownKeys);
Python (requests)
_, shortlist = getaff(
    "POST", f"/v1/sites/{site_id}/shortlist", {"keys": keys}, str(uuid.uuid4())
)
print(shortlist["added"], shortlist["unknownKeys"])
Respuesta (resumida)
{
  "data": [
    {
      "key": "instagram:1784140",
      "handle": "lea.mariage",
      "pfs": 86,
      "status": "selected",
      "prospectId": null
    }
  ],
  "max": 100,
  "added": 1,
  "full": false,
  "unknownKeys": []
}

6. Convertirlos en prospectos

POST /v1/sites/{id}/shortlist/to-prospects (ámbito write) escribe los creadores preseleccionados en sus prospectos privados. No gasta créditos, pero cuenta en la cuota de descubrimiento de su plan (quota). Sin keys, se añaden todos los creadores preseleccionados que aún no son prospectos. Una lectura gratuita de la biografía pública a veces encuentra ya una dirección (enriched). Guarde prospectIds.

curl
curl -s -X POST "$BASE/v1/sites/$SITE/shortlist/to-prospects" \
  -H "Authorization: Bearer $GETAFF_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"keys":["instagram:1784140"]}'
Node.js
const { json: written } = await getaff(
  'POST',
  `/v1/sites/${siteId}/shortlist/to-prospects`,
  { body: { keys }, idempotencyKey: crypto.randomUUID() },
);
const prospectIds = written.prospectIds;
Python (requests)
_, written = getaff(
    "POST",
    f"/v1/sites/{site_id}/shortlist/to-prospects",
    {"keys": keys},
    str(uuid.uuid4()),
)
prospect_ids = written["prospectIds"]
Respuesta (resumida)
{
  "written": 1,
  "alreadyInProspects": 0,
  "enriched": 0,
  "prospectIds": [
    "kJ2p8wQxYb_ig_1784140"
  ],
  "quota": {
    "used": 141,
    "limit": 2000
  }
}

7. Encontrar las direcciones de correo (de pago)

POST /v1/prospects/find-emails (ámbito spend, plan Pro) busca una dirección en los proveedores de datos para los prospectos que aún no la tienen. Nunca sustituye una dirección existente, y se aplica la lista de exclusión. Como gasta créditos, se hace en dos llamadas.

curl
IDEM=$(uuidgen)   # one key for the whole operation

# 7a. No confirmCost: 402 with the estimate, nothing spent
curl -s -X POST "$BASE/v1/prospects/find-emails" \
  -H "Authorization: Bearer $GETAFF_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $IDEM" \
  -d '{"ids":["kJ2p8wQxYb_ig_1784140"]}'

# 7b. A human agreed to error.details.estimate.credits: confirm, same key
curl -s -X POST "$BASE/v1/prospects/find-emails" \
  -H "Authorization: Bearer $GETAFF_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $IDEM" \
  -d '{"ids":["kJ2p8wQxYb_ig_1784140"],"confirmCost":1}'
Node.js
const idempotencyKey = crypto.randomUUID(); // one key for the whole operation
const ask = await getaff('POST', '/v1/prospects/find-emails', {
  body: { ids: prospectIds },
  idempotencyKey,
});
if (ask.status === 402 && ask.json.error.code === 'cost_confirmation_required') {
  const { credits } = ask.json.error.details.estimate;
  // askHuman: your own approval step (a prompt, a Slack button, a ticket…).
  if (await askHuman(`Look up ${prospectIds.length} e-mail(s) for up to ${credits} credits?`)) {
    const { json: found } = await getaff('POST', '/v1/prospects/find-emails', {
      body: { ids: prospectIds, confirmCost: credits },
      idempotencyKey,
    });
    console.log(found.found, 'found,', found.none, 'without an address');
  }
}
Python (requests)
idem = str(uuid.uuid4())  # one key for the whole operation
status, ask = getaff("POST", "/v1/prospects/find-emails", {"ids": prospect_ids}, idem)
if status == 402 and ask["error"]["code"] == "cost_confirmation_required":
    credits = ask["error"]["details"]["estimate"]["credits"]
    if input(f"Spend up to {credits} credits? [y/N] ").strip().lower() == "y":
        _, found = getaff(
            "POST",
            "/v1/prospects/find-emails",
            {"ids": prospect_ids, "confirmCost": credits},
            idem,
        )
        print(found["found"], "found,", found["none"], "without an address")

7a — Pedir la estimación

Envíe la llamada sin confirmCost. La respuesta es un 402 cost_confirmation_required, con la estimación exacta en error.details.estimate, y no se gasta nada. Muestre estimate.credits a una persona.

402
HTTP/1.1 402 Payment Required
Content-Type: application/problem+json

{
  "type": "https://www.getaff.org/docs/errors/cost_confirmation_required",
  "title": "Cost confirmation required",
  "status": 402,
  "detail": "Looking up 1 address(es) would spend up to 1 credits. Nothing was spent. Resend with `confirmCost` >= `error.details.estimate.credits`.",
  "instance": "urn:getaff:request:req_6f1c2a9e0b7d4c55a1f3e2d8c9b0a7e6",
  "error": {
    "code": "cost_confirmation_required",
    "message": "Looking up 1 address(es) would spend up to 1 credits. Nothing was spent. Resend with `confirmCost` >= `error.details.estimate.credits`.",
    "details": {
      "estimate": {
        "credits": 1,
        "action": "email_lookup",
        "units": 1,
        "lines": [],
        "remainingToday": 199
      }
    }
  }
}

7b — Confirmar y ejecutar

Cuando dé su conformidad, reenvíe la misma llamada con un confirmCost al menos igual a estimate.credits: es lo máximo que acepta gastar. Mantenga la misma Idempotency-Key: el 402 no se guardó, así que la llamada confirmada se ejecuta; si se agota el tiempo y la reenvía sin cambios, recibe su primer resultado (Idempotent-Replayed: true) en lugar de pagar dos veces. results[].status indica, prospecto por prospecto, found, none, daily_limit…

200 OK
{
  "looked": 1,
  "found": 1,
  "none": 0,
  "stopped": 0,
  "skipped": 0,
  "estimate": {
    "credits": 1,
    "action": "email_lookup",
    "units": 1,
    "lines": [],
    "remainingToday": 199
  },
  "results": [
    {
      "prospectId": "kJ2p8wQxYb_ig_1784140",
      "status": "found"
    }
  ]
}

8. Inscribir en una secuencia

POST /v1/outreach/sequences (ámbito write) programa la secuencia de tres pasos —un primer mensaje y dos seguimientos— para un prospecto, con todos los controles de la aplicación: un buzón conectado (indique mailboxId si tiene varios; GET /v1/outreach/mailboxes los lista), unos ajustes del remitente completos, un programa publicado, una dirección válida que no figure en la lista de exclusión y el régimen de cumplimiento del país del destinatario. attestations es su constancia de la base jurídica, como en el editor de GetAff; sin draft, se usa el borrador guardado para este prospecto en GetAff.

curl
curl -s -X POST "$BASE/v1/outreach/sequences" \
  -H "Authorization: Bearer $GETAFF_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"prospectId":"kJ2p8wQxYb_ig_1784140",
       "attestations":["b2b_role_relevant","legitimate_interest_assessed"]}'
Node.js
const { json: sequence } = await getaff('POST', '/v1/outreach/sequences', {
  body: {
    prospectId: prospectIds[0],
    attestations: ['b2b_role_relevant', 'legitimate_interest_assessed'],
  },
  idempotencyKey: crypto.randomUUID(),
});
console.log(sequence.data.state, sequence.data.steps.map((s) => s.dueAt));
Python (requests)
_, sequence = getaff(
    "POST",
    "/v1/outreach/sequences",
    {
        "prospectId": prospect_ids[0],
        "attestations": ["b2b_role_relevant", "legitimate_interest_assessed"],
    },
    str(uuid.uuid4()),
)
print(sequence["data"]["state"], [s["dueAt"] for s in sequence["data"]["steps"]])
Respuesta (resumida)
{
  "data": {
    "id": "kJ2p8wQxYb_ig_1784140",
    "prospectId": "kJ2p8wQxYb_ig_1784140",
    "mailboxEmail": "partners@nyna.ai",
    "state": "active",
    "regimeId": "eu_gdpr",
    "steps": [
      {
        "key": "opening",
        "state": "scheduled",
        "dueAt": "2026-10-27T08:30:00.000Z",
        "sentAt": null
      },
      {
        "key": "followup1",
        "state": "scheduled",
        "dueAt": "2026-11-01T08:30:00.000Z",
        "sentAt": null
      },
      {
        "key": "followup2",
        "state": "scheduled",
        "dueAt": "2026-11-08T08:30:00.000Z",
        "sentAt": null
      }
    ]
  }
}
Sin créditos, pero con correos reales

La inscripción no gasta créditos, por lo que no lleva confirmCost. Sí programa correos reales desde su buzón: haga que una persona apruebe el prospecto y el mensaje antes de la llamada; la herramienta MCP enrol_in_sequence lo impone con una confirmación en dos pasos. La API nunca envía un correo por sí misma; el buzón envía cada paso en su dueAt. Cada rechazo tiene su propio código: mailbox_required, draft_required, outreach_settings_incomplete, program_required, recipient_suppressed, compliance_blocked (con details.missing)…

Límites de uso

Cada respuesta lleva RateLimit-Policy y RateLimit (además de X-RateLimit-Limit y X-RateLimit-Remaining); un 429 lleva Retry-After, en segundos. También se aplican los topes de la aplicación; por ejemplo, 6 búsquedas de socios por minuto y por cuenta.

Límites de uso

Y después

Suscríbase al webhook prospect.replied para enterarse de las respuestas sin consultar la API; la referencia detalla cada campo y cada error de cada punto de acceso; el mismo recorrido está al alcance de un asistente de IA a través del servidor MCP.