GetAff
Documentation API

Démarrage rapide : de la clé au partenaire contacté

Tout le parcours de recrutement de partenaires par l’API REST, en une dizaine de minutes : huit appels, chacun en curl, Node.js et Python, avec la forme de la réponse. Une seule étape dépense des crédits, et elle demande d’abord.

Avant de commencer

  • Un compte GetAff avec au moins un site. Toute recherche de partenaires porte sur un site, et les sites se créent dans l’application.
  • Une clé d’API portant read, search, write et spend, créée depuis la page « Clés d’API ». Sans spend, tout fonctionne sauf l’étape 7, payante.
  • Le plan Pro pour l’étape 7 : les recherches d’e-mails payantes (comme les recherches en direct) ne sont pas incluses dans le plan gratuit, qui répond 403 plan_required.
  • Pour l’étape 8, une fois pour toutes dans l’application : une boîte d’envoi connectée, des réglages d’expéditeur complets (nom, société, adresse postale) et un programme publié.
Clés d’API

Mise en place

Gardez la clé dans une variable d’environnement, sur un serveur — jamais dans un navigateur. Les exemples Node.js et Python utilisent la petite fonction ci-dessous : elle envoie la clé, encode le corps en JSON, pose Idempotency-Key quand on lui en donne une et lève une exception sur toute erreur sauf 402, que l’étape 7 traite. Les erreurs sont en application/problem+json : décidez d’après error.code, jamais d’après le message.

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
L’idempotence, sur chaque écriture

Chaque écriture de ce guide accepte un en-tête Idempotency-Key (8 à 128 caractères parmi A-Z a-z 0-9 _ - : . ; un UUID convient). Un nouvel essai avec la même clé et le même corps dans les 24 heures rejoue le premier succès avec Idempotent-Replayed: true au lieu de s’exécuter à nouveau ; la même clé avec un autre corps répond 409 idempotency_conflict. Les erreurs ne sont pas conservées : une requête corrigée peut reprendre sa clé. Une clé par opération, réutilisée pour ses nouveaux essais.

1. Lister vos sites

GET /v1/sites renvoie les sites de votre compte, chacun avec le programme d’affiliation qu’il propose sur GetAff. Notez l’id du site pour lequel vous recrutez : tous les appels suivants l’utilisent.

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"]
Réponse (abrégée)
{
  "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. Fixer les critères de recherche

PATCH /v1/sites/{id}/criteria (portée write) remplace chaque champ envoyé et conserve les autres : mots-clés, niches, plateformes, pays, langues, tranches d’audience, ancienneté de la dernière publication. { "reset": true } revient aux critères que GetAff déduit du site. Sautez cette étape pour chercher avec ces critères déduits.

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()),
)
Réponse (abrégée)
{
  "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. Lire les résultats classés

GET /v1/sites/{id}/partner-search/latest parcourt la dernière recherche (conservée 30 jours ; chaque recherche est aussi enregistrée sous /partner-search/history). Chaque résultat porte sa key, un score d’adéquation fit.pfs de 0 à 100 et fit.reasons, la raison de son rang. minPfs ne garde que les meilleures adéquations ; suivez page.nextCursor pour la suite.

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]]
Réponse (abrégée)
{
  "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. Sélectionner les meilleurs

POST /v1/sites/{id}/shortlist (portée write) ajoute des résultats de la dernière recherche par leur key, jusqu’à 50 par appel ; la sélection en contient 100 au plus. Un créateur déjà sélectionné est actualisé, pas dupliqué ; une clé absente des derniers résultats revient dans unknownKeys et rien n’est ajouté pour elle.

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"])
Réponse (abrégée)
{
  "data": [
    {
      "key": "instagram:1784140",
      "handle": "lea.mariage",
      "pfs": 86,
      "status": "selected",
      "prospectId": null
    }
  ],
  "max": 100,
  "added": 1,
  "full": false,
  "unknownKeys": []
}

6. En faire des prospects

POST /v1/sites/{id}/shortlist/to-prospects (portée write) écrit les créateurs sélectionnés dans vos prospects privés. Aucun crédit n’est dépensé, mais l’opération compte dans le quota de découverte de votre plan (quota). Sans keys, tous les créateurs sélectionnés qui ne sont pas encore des prospects sont ajoutés. Une lecture gratuite de la bio publique trouve parfois déjà une adresse (enriched). Conservez 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"]
Réponse (abrégée)
{
  "written": 1,
  "alreadyInProspects": 0,
  "enriched": 0,
  "prospectIds": [
    "kJ2p8wQxYb_ig_1784140"
  ],
  "quota": {
    "used": 141,
    "limit": 2000
  }
}

7. Trouver les adresses e-mail (payant)

POST /v1/prospects/find-emails (portée spend, plan Pro) cherche une adresse auprès des fournisseurs de données pour les prospects qui n’en ont pas encore. Une adresse existante n’est jamais remplacée, et la liste d’exclusion s’applique. Comme l’opération dépense des crédits, elle se fait en deux appels.

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 — Demander l’estimation

Envoyez l’appel sans confirmCost. La réponse est un 402 cost_confirmation_required, avec l’estimation exacte dans error.details.estimate — et rien n’est dépensé. Montrez estimate.credits à un humain.

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 — Confirmer et exécuter

Une fois son accord obtenu, renvoyez le même appel avec un confirmCost au moins égal à estimate.credits : c’est le maximum que vous acceptez de dépenser. Gardez la même Idempotency-Key : le 402 n’a pas été conservé, l’appel confirmé s’exécute donc ; s’il expire et que vous le renvoyez à l’identique, vous recevez son premier résultat (Idempotent-Replayed: true) au lieu de payer deux fois. results[].status indique, prospect par prospect, 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. Inscrire dans une séquence

POST /v1/outreach/sequences (portée write) programme la séquence en trois étapes — un premier message et deux relances — pour un prospect, avec tous les contrôles de l’application : une boîte d’envoi connectée (passez mailboxId si vous en avez plusieurs ; GET /v1/outreach/mailboxes les liste), des réglages d’expéditeur complets, un programme publié, une adresse valide absente de la liste d’exclusion et le régime de conformité du pays du destinataire. attestations constitue votre trace de la base légale, comme dans l’éditeur de GetAff ; sans draft, c’est le brouillon enregistré pour ce prospect dans GetAff qui est utilisé.

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"]])
Réponse (abrégée)
{
  "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
      }
    ]
  }
}
Aucun crédit, mais de vrais e-mails

L’inscription ne dépense aucun crédit : elle ne prend donc pas de confirmCost. Elle programme en revanche de vrais e-mails depuis votre boîte d’envoi : faites valider le prospect et le message par un humain avant l’appel — l’outil MCP enrol_in_sequence l’impose par une confirmation en deux temps. L’API n’envoie jamais d’e-mail elle-même ; la boîte d’envoi expédie chaque étape à son dueAt. Chaque refus a son propre code : mailbox_required, draft_required, outreach_settings_incomplete, program_required, recipient_suppressed, compliance_blocked (avec details.missing)…

Limites de débit

Chaque réponse porte RateLimit-Policy et RateLimit (ainsi que X-RateLimit-Limit et X-RateLimit-Remaining) ; un 429 porte Retry-After, en secondes. Les plafonds de l’application s’appliquent aussi — par exemple 6 recherches de partenaires par minute et par compte.

Limites de débit

Et ensuite

Abonnez-vous au webhook prospect.replied pour être prévenu des réponses au lieu d’interroger l’API ; la référence détaille chaque champ et chaque erreur de chaque point d’accès ; le même parcours est accessible à un assistant IA par le serveur MCP.