Affiliatant

API reference

The Affiliatant API

Everything a merchant needs on one page: a working tracked click and a test conversion in ten minutes, then the whole reference.

Base URL: https://affiliatant.com/v1

The path is /v1 and it stays /v1. Changes that can be absorbed by a dated revision get one; see Versioning. A move to api.affiliatant.com is planned and will be announced with both hostnames serving for a transition period.


Quickstart: ten minutes

Four calls. Everything below runs against your test key, so nothing you do here touches your live reports, and you can delete it all afterwards by switching back to live.

1. Get a test key

Merchant console, Settings, API keys. You get two:

aff_sk_test_...    secret.      Server to server. Can do everything.
aff_pk_test_...    publishable. Safe in a browser. Clicks and leads only.

The secret is shown once. We store a SHA-256 of it and cannot recover it.

2. Record a click

Either send the visitor through the redirect (https://<your tracking domain>/c/<link token>), which sets everything up for you, or, if you render your own landing page, record it yourself:

curl https://affiliatant.com/v1/clicks \
  -H "Authorization: Bearer aff_sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "link_token":   "7Qd2kVn4",
    "landing_url":  "https://shop.example.com/widgets",
    "ip":           "203.0.113.9",
    "user_agent":   "Mozilla/5.0 ...",
    "referrer":     "https://blog.example.com/best-widgets"
  }'

You get back a click:

{
  "id": "01JAJ8V7Q3K5R9W2XY4ZB6C8DE",
  "object": "click",
  "livemode": false,
  "affiliate_id": 14,
  "destination": "https://shop.example.com/widgets?aftid=01JAJ8V7Q3K5R9W2XY4ZB6C8DE"
}

id is the referral id. Put it in a first-party cookie, or keep it against the session. It is the only thing you need to carry to the order.

The query parameter is aftid and the cookie our own script sets is aff_click. Both, written down, because neither competitor documents the cookie name and that alone blocks a purely server-side integration: if you read the cookie yourself, that is the name to read.

landing_url must be a host on your programme's destination allowlist. A click endpoint that recorded any URL a caller named would be an open redirect with a database behind it.

3. Record the conversion

curl https://affiliatant.com/v1/conversions \
  -H "Authorization: Bearer aff_sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-10042" \
  -d '{
    "program_id":        1,
    "external_order_id": "10042",
    "amount_minor":      12500,
    "currency":          "GBP",
    "tax_minor":         2500,
    "occurred_at":       "2026-10-20T14:03:00Z",
    "referral":          "01JAJ8V7Q3K5R9W2XY4ZB6C8DE"
  }'
{
  "id": 9001,
  "object": "conversion",
  "livemode": false,
  "status": "pending",
  "amount_minor": 12500,
  "commissionable_minor": 10000,
  "currency": "GBP",
  "referral_source": "referral",
  "resource_version": 1792588980123
}

program_id can be left out if your account has exactly one active programme, or replaced with store_id if you have connected a store.

4. Read it back without storing our id

curl "https://affiliatant.com/v1/conversions?external_order_id=10042" \
  -H "Authorization: Bearer aff_sk_test_..."

That is the whole integration. The merchant console has a tracking status indicator that turns green on your first real click, and a test-click generator beside it, so you can confirm the live path without emailing anybody.

Sending the referral id: four ways, pick whichever you have

POST /v1/conversions takes the referral from any one of these, in this order of precedence:

FieldWhen you use it
click_id, or its alias aftidYou kept the id explicitly
referralYou posted a hidden form field
cookieYou read the aff_click cookie server-side and are passing the value through
metadata.<key>Your billing provider carries it. Name the key once on the store (referral_metadata_key) and send the whole metadata object
coupon_codeYou have nothing but the code the customer typed

A referral id is always a 26 character ULID. Sending something else in one of the first four fields is a 400, naming the field, rather than a conversion that silently never attributes.

coupon_code is not an alternative to the others. Send both when you have both: choosing between a code and a click is your programme's coupon rule, and the decision is recorded on the attribution so an affiliate who disputes it can be shown why.


Keys and authentication

Authorization: Bearer aff_sk_live_xxxxxxxxxxxxxxxxxxxxxxxx

Four key shapes, and the prefix tells you everything:

PrefixKindModeMay do
aff_sk_live_SecretLiveEverything its scopes allow
aff_sk_test_SecretTestEverything its scopes allow, against sandbox data
aff_pk_live_PublishableLiveCreate clicks and leads. Nothing else, ever
aff_pk_test_PublishableTestCreate clicks and leads. Nothing else, ever

The publishable restriction is absolute. A publishable key sits in a browser where anyone can read it. Whatever scopes are recorded against it, including a wildcard, it can reach POST /v1/clicks and POST /v1/leads and nothing else. A publishable key that could create a conversion would be a forgeable commission, and the whole product rests on a commission not being forgeable. Attempting anything else returns:

{
  "error": {
    "message": "A publishable key may only create clicks and leads. Use your secret key from a server.",
    "code": "publishable_key_forbidden"
  }
}

POST /v1/clicks and POST /v1/leads send Access-Control-Allow-Origin: * and answer a CORS preflight, so a publishable key works from page JavaScript. No other endpoint does.

Scopes

A secret key carries a list, or ["*"].

clicks:write        leads:write
conversions:read    conversions:write
affiliates:read     affiliates:write
programs:read       links:read          coupons:read
commissions:read    payouts:read
events:read         webhooks:read       webhooks:write

A key without the scope an endpoint needs returns 403 insufficient_scope.

Storage and rotation

We store a SHA-256 of the key and a twenty character visible prefix. We cannot tell you what a key was. A revoked key returns 401 revoked_api_key and names the date it was revoked, because the commonest cause of a sudden 401 is a key somebody else on the team retired.

last_used_at is written at most once every five minutes per key, so it is accurate to within five minutes and not to the second.


Test mode

A real sandbox, keyed off the key prefix. There is no header and no flag: you use a test key or you do not get it.

Separated between modes. Clicks, leads, conversions, commissions, events and webhook endpoints. A test conversion never appears in a live list, never reaches a live webhook endpoint, and never appears in a live report.

Shared between modes. Programmes, affiliates, links and coupons. These are configuration, and making you build a second set of affiliates to test against would cost more than it protects. Your test conversions attribute to your real affiliates under your real commission rules, so a test conversion produces the commission figure a live one would.

Live only. Payout batches. Affiliatant instructs payouts and never moves money, so a sandbox payout would be a sandbox instruction against a real provider. GET /v1/payouts with a test key returns an empty list, not an error.

Reading a live object with a test key is a 404, not a 403. A 403 would confirm that an object with that id exists on the other side.

Every object and every webhook payload carries "livemode": true | false.


Versioning

Affiliatant-Version: 2026-10-20

Omit the header and you get the revision your account is pinned to; a new account is pinned to the current one. Send a revision we do not serve and you get 400 unknown_version rather than a silent fallback, because a client that thinks it pinned and did not is worse off than a client that was told.

Every response carries the resolved revision in an Affiliatant-Version header.

RevisionStatus
2026-10-20Current

An event's structure is fixed by the revision in force when it occurred. A webhook delivered in January and replayed in June arrives in January's shape, because that is what your parser was written against. The event carries its own api_version; your request's revision does not change it.


Idempotency

Idempotency-Key: order-10042

Required on every POST. Client generated, any unique string up to 255 characters, retained 24 hours. These are Stripe's semantics exactly, because you already know them.

  1. Send the same key again and you get the stored status and body, with
  2. Idempotent-Replayed: true. The handler does not run a second time.

  3. That includes stored errors. If the first attempt returned 400, the
  4. replay returns the same 400 with the same body. Giving the retry a second chance to succeed against changed state is how one order becomes two commissions.

  5. The same key with different parameters is itself an error:
  6. 400 idempotency_key_reuse. Use a new key for a different request. Key order inside a JSON object does not count as different.

  7. A second request arriving while the first is still running gets
  8. 409 idempotency_in_flight. Retry in a moment. Two concurrent requests with one key never both execute.

Keys are scoped to your account and to the endpoint, so the same string on /v1/conversions and /v1/leads is two independent claims.

A failure at our end (500) does not store a result. The key is released and your retry really runs.

Your own order id is a good key. You are also protected without one: a uniqueness constraint on (program_id, external_order_id, kind) means a replayed webhook from your billing provider cannot create a second conversion. The response is then 200 rather than 201 and the body carries the conversion that already existed.


Pagination

Cursor based, newest first.

GET /v1/conversions?limit=50&starting_after=9001
ParameterDefaultNotes
limit251 to 100
starting_afterAn object id. Returns the page below it
ending_beforeAn object id. Returns the page above it

The body is a list object:

{
  "object": "list",
  "url": "/v1/conversions",
  "has_more": true,
  "data": [ ... ]
}

and a Link header carries the walk:

Link: <https://affiliatant.com/v1/conversions?limit=50&starting_after=8951>; rel="next"

Follow rel="next" until has_more is false.

Cursor and not page, deliberately. Conversion volume is append-heavy. With ?page=2, three conversions arriving while you read page one push three rows you have already seen onto page two, and you either process them twice or never see them. A cursor on the id cannot drift: rows that arrive during a walk have higher ids and are simply not in it. To pick them up, start the list again.


Rate limits

Per key. Two windows, because one does not do the job: a steady window stops a client consuming a day's allowance in an hour, and a burst window stops the same client spending the steady allowance in two seconds.

WindowDefault
Burst100 requests per 10 seconds
Steady1,000 requests per 60 seconds

These are the defaults and they are configurable per install. They will be revisited once there is real traffic to set them from.

On every response, including every error and including a 401:

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 994
X-RateLimit-Reset: 1792588980

The figures describe whichever window is nearest its ceiling, which is the one your backoff should respect.

On every 429, additionally:

Retry-After: 7

Seconds. Wait that long. A client forced to derive its wait from a reset timestamp gets it wrong under clock skew and then retries into the same wall, which is why Retry-After is here and is not optional.

Unauthenticated requests are counted against the calling address rather than against a key they have not proved they own.


Errors

Three fields. A stable machine code you can branch on, a human message for your log, and param naming the field at fault when one is.

{
  "error": {
    "message": "Missing required parameter: currency.",
    "code": "missing_parameter",
    "param": "currency"
  }
}

A published code keeps its meaning. A new condition gets a new code rather than a new meaning for an old one.

CodeHTTPMeaning
missing_api_key401No Authorization: Bearer header
invalid_api_key401Unknown key, or the secret did not match
revoked_api_key401The key was revoked
tenant_inactive403The account is suspended or closed
publishable_key_forbidden403A publishable key tried to do more than create a click or a lead
insufficient_scope403Valid key, missing scope
unauthorized401Authentication needed and not accepted
forbidden403Authenticated, but not allowed to do that
invalid_request400Could not be understood
missing_parameter400See param
invalid_parameter400See param
unknown_version400Affiliatant-Version names a revision we do not serve
not_found404No such object on this account, in this mode
method_not_allowed405The path exists, the method does not
idempotency_key_required400Every POST needs one
idempotency_key_too_long400Maximum 255 characters
idempotency_key_reuse400Same key, different parameters
idempotency_in_flight409The first request with this key is still running
rate_limited429See Retry-After
conflict409Conflicts with the object's current state
mode_mismatch400A test-mode object cannot be sent to a live destination, or the reverse
destination_refused400A webhook URL that resolves somewhere we will not send to
endpoint_paused409Resume the endpoint before replaying to it
not_implemented501That part of the platform is not built yet
server_error500Ours. It has been logged

Money

Every amount is an integer in the currency's minor unit, and it always travels with its currency. 19.99 GBP is {"amount_minor": 1999, "currency": "GBP"}. There is no field anywhere in this API carrying a decimal number of pounds, and there never will be.

A JSON number is refused for money, whatever its value: 19.99 is not representable in binary floating point, your encoder and our decoder need not agree on what it became, and a commission computed from the difference is a dispute nobody can settle.

If your order total lives in a string, send it as one and we will parse it exactly:

{ "amount": "125.00", "currency": "GBP" }

Both amount_minor and amount are accepted on input. Only amount_minor comes back.

A rate is basis points. 2.5% is 250.


Endpoint reference

The six a headless merchant needs

POST   /v1/clicks                      create a click from a link token
POST   /v1/leads                       attach a referral to a customer before revenue
POST   /v1/conversions                 record a sale or a renewal
POST   /v1/conversions/{id}/reverse    refund, chargeback or void, partial supported
GET    /v1/conversions?external_order_id=...
GET    /v1/affiliates/{id}/magic_link  one-time SSO into the affiliate portal

Everything else

GET    /v1/conversions/{id}
GET    /v1/leads
GET    /v1/affiliates                  GET /v1/affiliates/{id}
GET    /v1/programs                    GET /v1/programs/{id}
GET    /v1/links                       GET /v1/links/{id}
GET    /v1/coupons                     GET /v1/coupons/{id}
GET    /v1/commissions                 GET /v1/commissions/{id}
GET    /v1/payouts                     GET /v1/payouts/{id}
GET    /v1/events                      GET /v1/events/{id}
POST   /v1/events/{id}/replay

GET    /v1/webhook_endpoints           POST   /v1/webhook_endpoints
GET    /v1/webhook_endpoints/{id}      PUT    /v1/webhook_endpoints/{id}
DELETE /v1/webhook_endpoints/{id}
POST   /v1/webhook_endpoints/{id}/rotate_secret
POST   /v1/webhook_endpoints/{id}/replay
GET    /v1/webhook_endpoints/{id}/deliveries

Programmes, affiliates, links and coupons are read only over the API. A programme holds the attribution window, the coupon rule and the holding period, and changing one of those changes what every affiliate is owed; that belongs in the merchant console where the change is shown, confirmed and audited, not in a key that might be in a cron script.

POST /v1/conversions

FieldRequiredNotes
external_order_idyesYour order reference. Up to 128 characters
amount_minor or amountyesInteger minor units, or a decimal string
currencyyesISO 4217
program_idif ambiguousOmit when the account has one active programme
store_idnoNames the programme and the metadata key
kindnosale (default), recurring, lead, trial, upgrade, refund, chargeback
tax_minor, shipping_minornoExcluded from the commissionable amount if your programme says so
occurred_atnoISO 8601. Defaults to now. A future instant is refused
external_customer_idnoNeeded for recurring, lifetime and new-customer rules
customer_is_newnoBoolean
event_idnoYour own id, deduplicating a pixel against a webhook
channelnoFor cross-channel deduplication
itemsnoLine items, for product and category rules
the referralnoSee the four ways

kind is what lets one model serve both ecommerce and SaaS. A renewal arrives with the same referral id and "kind": "recurring", and becomes a second commission rather than a duplicate of the first.

A conversion is never discarded. One with a missing, unknown or out-of-window referral is stored with status unmatched and appears in the unmatched report, where it can be investigated and fixed. It is not an error.

The response is 201 for a new conversion and 200 for one that already existed. Attribution and commission happen immediately afterwards, as a job, so that we are never the reason your checkout is slow. GET /v1/conversions/{id} returns the attribution decision alongside the conversion once it has been made.

POST /v1/conversions/{id}/reverse

FieldRequiredNotes
reasonnorefund (default), chargeback, fraud, manual, cancellation
amount_minornoOmit for a full reversal
commission_idif partial and multi-tierWhich commission the partial amount applies to

A reversal is a new record and a negative double-entry ledger movement. Nothing edits the original commission, which is what lets you answer an affiliate who asks why a figure changed.

Returns a signed, single-use URL into the affiliate portal, valid for fifteen minutes. Call it from your own application, where the affiliate is already signed in, and redirect them.

{
  "object": "magic_link",
  "affiliate_id": 14,
  "url": "https://affiliatant.com/portal/magic/eyJhIjoxNC...",
  "expires_at": "2026-10-20T14:18:00.000Z",
  "expires_in_seconds": 900,
  "single_use": true
}

This solves affiliate login for every merchant who already has user accounts and will not ask their partners to keep a second password.


Webhooks

Delivery semantics

Delivery is at-least-once and unordered.

Those words, plainly, because the commonest integration failure in this category is a consumer that assumed exactly-once and ordered and was never told otherwise until it had double counted a month of commission.

What that means for your code:

  • You will sometimes get the same event twice. Every delivery carries
  • Affiliatant-Event-Id, stable across every retry and every replay. Record it and ignore a repeat.

  • Events can arrive out of order. Every payload carries
  • resource_version, a monotonically increasing millisecond epoch for that object. Discard anything whose resource_version is not newer than what you already hold. Do not compare timestamps across two machines.

  • Acknowledge fast, process later. Any 2xx within ten seconds counts.
  • Queue the work and return.

Required response

Any 2xx within ten seconds.

A 3xx is a failure, not a redirect we follow. Point your endpoint at its final URL. We do not follow redirects, because a redirect is how an endpoint gets quietly repointed past the destination check we just ran.

The retry ladder

Published, not a vague promise. Measured from the attempt that failed:

AttemptThen wait
11 minute
25 minutes
315 minutes
41 hour
52 hours
65 hours
710 hours
820 hours
933 hours 20 minutes
10the ladder is spent

Ten attempts in all. The last falls 71 hours and 41 minutes after the first, which is the "roughly 72 hours" quoted elsewhere. If you are sizing your own alerting off this table, that figure is the one to use.

When an endpoint exhausts the ladder

It is paused, and the account owner is emailed. It is never deleted.

Every undelivered event stays replayable for 30 days, from the merchant console and from the API. An endpoint that silently disappears is how a merchant discovers three weeks later that they have no conversion data.

To recover: fix your receiver, set the endpoint back to active with PUT /v1/webhook_endpoints/{id}, then replay the window you missed:

curl https://affiliatant.com/v1/webhook_endpoints/3/replay \
  -H "Authorization: Bearer aff_sk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"from": "2026-10-18T00:00:00Z", "to": "2026-10-20T00:00:00Z"}'

A replay restarts the ladder from the beginning. GET /v1/webhook_endpoints/{id}/deliveries is the delivery log, with the response code, the error and the duration of every attempt.

Headers on every delivery

Content-Type: application/json; charset=utf-8
User-Agent: Affiliatant-Webhooks/1.0 (+https://affiliatant.com/docs/api)
Affiliatant-Event-Id: 01JAJ8V7Q3K5R9W2XY4ZB6C8DE
Affiliatant-Event-Type: conversion.created
Affiliatant-Delivery-Attempt: 1
Affiliatant-Version: 2026-10-20
Affiliatant-Signature: t=1792588980,v1=5257a869e7...

Verifying the signature

HMAC-SHA256 over t + "." + raw body, keyed with your endpoint's secret. Tolerance is five minutes, not five seconds, because we cannot assume clock discipline on a small merchant's server and a rejected webhook is worse than a slightly wide window when the body is signed anyway.

Three rules the recipe below follows and yours must too:

  1. Use the raw request body. Not a re-encoded decode of it. One space
  2. different and the signature will not match.

  3. Compare in constant time. hash_equals, never ===.
  4. Ignore any scheme other than v1. A future v2 will be added
  5. alongside; a verifier that rejects an unknown scheme breaks on the day it appears.

<?php
declare(strict_types=1);

/**
 * Verify an Affiliatant webhook. No dependencies.
 *
 * @param string $header Affiliatant-Signature header, verbatim
 * @param string $body   the raw request body, verbatim
 * @param string ...$secrets your endpoint secret, plus the previous one
 *                           during the 24 hours after a rotation
 */
function affiliatant_verify(string $header, string $body, string ...$secrets): bool
{
    $tolerance = 300;
    $timestamp = null;
    $supplied  = [];

    foreach (explode(',', $header) as $part) {
        $pair = explode('=', trim($part), 2);
        if (count($pair) !== 2) {
            continue;
        }
        if ($pair[0] === 't' && ctype_digit($pair[1])) {
            $timestamp = (int) $pair[1];
        } elseif ($pair[0] === 'v1') {
            // Anything that is not v1 is ignored, not rejected, so a future
            // scheme added alongside this one does not break you.
            $supplied[] = $pair[1];
        }
    }

    if ($timestamp === null || $supplied === []) {
        return false;
    }

    // Replay protection. Five minutes either side.
    if (abs(time() - $timestamp) > $tolerance) {
        return false;
    }

    foreach ($secrets as $secret) {
        if ($secret === '') {
            continue;
        }
        $expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
        foreach ($supplied as $candidate) {
            // Constant time. A === here leaks the signature one byte at a
            // time to anyone who can measure the response.
            if (hash_equals($expected, $candidate)) {
                return true;
            }
        }
    }

    return false;
}

// Using it.
$body   = file_get_contents('php://input');
$header = $_SERVER['HTTP_AFFILIATANT_SIGNATURE'] ?? '';

if (!affiliatant_verify($header, $body, getenv('AFF_WEBHOOK_SECRET') ?: '')) {
    http_response_code(400);
    exit;
}

$event = json_decode($body, true);

// At-least-once: you will see this id again. Record it and return early.
if (already_handled($event['id'])) {
    http_response_code(200);
    exit;
}

// Unordered: discard anything older than what you already hold.
if (!is_newer($event['data']['object'], $event['resource_version'])) {
    http_response_code(200);
    exit;
}

// Acknowledge inside ten seconds, then do the work.
queue_for_processing($event);
http_response_code(200);

Rotating a secret

POST /v1/webhook_endpoints/{id}/rotate_secret

The new secret is returned once. The old secret stays valid for 24 hours, and every delivery in that window carries one v1= signature per active secret in a single Affiliatant-Signature header:

Affiliatant-Signature: t=1792588980,v1=<new>,v1=<old>

Pass both secrets to your verifier during the window and deploy whenever your release process allows. Without the overlap, rotation means dropped deliveries, so nobody rotates, so a leaked secret stays live.

A secret is returned by POST /v1/webhook_endpoints and by rotate_secret, and by nothing else. A GET never carries it: an endpoint listing that included every signing secret would turn one read-scoped key into the ability to forge every webhook we send.

Payload

A full snapshot by default. "payload_style": "thin" on the endpoint gives ids only, for consumers who would rather re-fetch and be certain they have current state.

{
  "id": "01JAJ8V7Q3K5R9W2XY4ZB6C8DE",
  "object": "event",
  "type": "conversion.created",
  "api_version": "2026-10-20",
  "livemode": true,
  "occurred_at": "2026-10-20T14:03:00.412Z",
  "resource_version": 1792588980412,
  "data": {
    "object": { "...": "the conversion" }
  }
}

Thin:

{
  "id": "01JAJ8V7Q3K5R9W2XY4ZB6C8DE",
  "object": "event",
  "type": "conversion.created",
  "data": { "object": { "id": "9001", "object": "conversion" } },
  "related_url": "https://affiliatant.com/v1/conversions/9001"
}

The catalogue

Noun then verb. These are every type that exists; nothing else can be emitted.

Affiliates

affiliate.created      affiliate.approved     affiliate.rejected
affiliate.updated      affiliate.suspended

Links and coupons

link.created           link.updated           link.archived
coupon.created         coupon.updated         coupon.deactivated

Conversions

lead.created
conversion.created     conversion.updated     conversion.reversed

Money

commission.created     commission.approved    commission.voided
commission.paid
payout.created         payout.instructed      payout.settled
payout.failed

Configuration and integrations

program.updated
store.connected        store.disconnected     store.sync_failed
document.issued

Subscribe to "*" for everything, to a prefix such as "conversion.*" for a family, or to exact types. An unrecognised type is refused when you register the endpoint, naming the typo, rather than being accepted and never firing.

store.sync_failed is deliberate. Every ecommerce platform has a way to go quiet: Shopify deletes a subscription after 8 failures, BigCommerce deactivates after 48 hours, WooCommerce disables after 5. You get told your integration has stopped reporting, by an event you can route into your own alerting.

Endpoints and the sandbox

A webhook endpoint belongs to the mode of the key that created it. A test event is only ever delivered to a test endpoint and a live event only to a live endpoint. A delivery that crosses the line is recorded as suppressed, not as a failure, because nothing went wrong.

What we will not send to

A webhook URL must be https, on port 443, with no credentials in it, and must resolve to a public address. We refuse loopback, RFC 1918 private space, carrier-grade NAT, link-local (including the cloud metadata address), unique-local IPv6, multicast and reserved ranges. This is checked when you save the endpoint and again at delivery time, because DNS can be repointed after an endpoint is accepted. A URL we will not send to is 400 destination_refused.

A published static source IP list, so you can firewall our deliveries, is not available yet. It will be added here rather than guessed at.


Reading the event log

Every event is recorded before it is delivered. If your receiver was down and you want to know what you missed, read the log rather than guessing:

curl "https://affiliatant.com/v1/events?type=conversion.created&occurred_after=2026-10-19T00:00:00Z" \
  -H "Authorization: Bearer aff_sk_live_..."

and replay a single one to a specific endpoint:

curl https://affiliatant.com/v1/events/01JAJ8V7Q3K5R9W2XY4ZB6C8DE/replay \
  -H "Authorization: Bearer aff_sk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"webhook_endpoint_id": 3}'

Events are kept and replayable for 30 days.