aflink.

AFLINK / API

Connect your SaaS to Aflink

This guide is the shortest path for your SaaS to pay affiliate commissions through Aflink. The integration has three parts, and none of them sits in your checkout path or slows your pages down:

  1. Click — the affiliate shares a https://aflink.app/r/{code} link. Aflink records the click and redirects the visitor to your site with ?_aflink=att_….
  2. Capture — your site stores that token with the customer's signup.
  3. Events — when a referred customer pays, renews or is refunded, your server sends a signed event to Aflink. The commission follows your program's policy.

No third-party script, no pixel and no synchronous call to Aflink at checkout.

  1. Sign in at aflink.app with your CIATTO account and open your company.
  2. Programs → Create program: enter your site address (where links land), the commission policy and the terms. The suggested values (15%, 12 monthly payments, 30 attribution days, 30 hold days) are editable.
  3. Integrations → New credential: create a TEST credential to validate and later a LIVE one for production. The API key and signing secret are shown only once: keep them in your server's secret store, never in the browser, app or repository.

Prefer the monthly payments model (default): it does not require service windows. The service months model is described at the end.

1. Capture the click token

Visitors arrive with ?_aflink=att_…. Keep the token for at most the program's attribution period (30 days by default). A newer token replaces the previous one (last click wins) until the first payment.

In the middleware that serves your public pages:

if the URL has _aflink matching ^att_[A-Za-z0-9_-]{20,128}$:
    set cookie aflink_att=<token>; Max-Age=2592000; Path=/; Secure; HttpOnly; SameSite=Lax
    (use Domain=.yourdomain.com when signup happens on another subdomain)

Option B — in the browser

A few inline lines with no dependency and no network request. Put it on the landing page:

<script>
  (function () {
    try {
      var t = new URLSearchParams(location.search).get("_aflink");
      if (!t || !/^att_[A-Za-z0-9_-]{20,128}$/.test(t)) return;
      document.cookie = "aflink_att=" + t + "; Max-Age=2592000; Path=/; SameSite=Lax; Secure";
      var u = new URL(location.href);
      u.searchParams.delete("_aflink");
      history.replaceState(null, "", u);
    } catch (e) {}
  })();
</script>

At signup

When the account is created, read the aflink_att cookie on the server and store the token on the customer record, for example in aflink_token and aflink_token_at columns. No token means the customer was not referred and no event should be sent.

2. Choose the events

Send events only for referred customers, that is, customers with an affiliate token or coupon. This keeps volume low and avoids sharing data about customers who did not come from an affiliate.

When it happens in your SaaSEventRequired fields besides the common ones
First payment of a subscriptionsubscription.startedattributionToken (or couponCode), externalSubscriptionId, customerExternalId, occurredAt
Each later subscription paymentsubscription.renewedexternalSubscriptionId, customerExternalId, subscriptionCycle (2, 3, …), occurredAt
One-time purchaseorder.paidattributionToken (or couponCode), customerExternalId
Full or partial refundrefund.createdoriginalExternalPaymentId
Chargebackchargeback.createdoriginalExternalPaymentId

Fields common to every event:

FieldRule
externalPaymentIdPayment ID in your system or gateway (3–255 characters). Unique per payment.
customerEmailCustomer email.
customerExternalIdStable customer ID in your SaaS, never the email (up to 255). E.g. yoursaas:account_123.
valueAmount the commission is calculated on, as decimal text: "189.00". Always positive, refunds included (up to 2 decimals).
currency3-letter code equal to the program's, e.g. BRL.
occurredAtPayment time in RFC 3339 (UTC). At most 5 minutes in the future.
metadataOptional. Do not include personal data.

The first payment binds the customer to the affiliate permanently: renewals and refunds are matched by customerExternalId and originalExternalPaymentId, without the token. Send the original payment before its refund.

Example subscription.started:

{
  "type": "subscription.started",
  "data": {
    "attributionToken": "att_AbCdEf…",
    "customerExternalId": "yoursaas:account_123",
    "customerEmail": "[email protected]",
    "externalPaymentId": "in_1Q2w3E",
    "externalSubscriptionId": "sub_9Z8y7X",
    "value": "189.00",
    "currency": "BRL",
    "occurredAt": "2026-10-22T14:30:00Z"
  }
}

3. Sign and send

POST https://api.aflink.dev/v1/events with Content-Type: application/json and:

HeaderValue
AuthorizationBearer <API key>
Idempotency-KeyDeterministic event key, e.g. subscription.started:in_1Q2w3E (8–255).
X-Aflink-TimestampUnix seconds (10 digits). Clock within 5 minutes.
X-Aflink-NonceFresh random value on every attempt, 16–128 characters [A-Za-z0-9_-].
X-Aflink-Signaturev1= + hex of HMAC-SHA256(signing secret, timestamp + "." + nonce + "." + body).

The signature covers the exact bytes of the body sent. Bodies are limited to 64 KB. Calls made from a browser (with Cookie or Origin) are rejected.

Node.js 18+

import crypto from "node:crypto";

export async function sendAflinkEvent(idempotencyKey, body) {
  // body: exact JSON string stored in the outbox (do not serialize again).
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(24).toString("base64url");
  const signature = crypto
    .createHmac("sha256", process.env.AFLINK_SIGNING_SECRET)
    .update(`${timestamp}.${nonce}.${body}`)
    .digest("hex");
  return fetch("https://api.aflink.dev/v1/events", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.AFLINK_API_KEY}`,
      "Idempotency-Key": idempotencyKey,
      "X-Aflink-Timestamp": timestamp,
      "X-Aflink-Nonce": nonce,
      "X-Aflink-Signature": `v1=${signature}`,
    },
    body,
    signal: AbortSignal.timeout(10_000),
  });
}

Python 3

import hashlib, hmac, os, secrets, time, urllib.request

def send_aflink_event(idempotency_key: str, body: bytes):
    timestamp = str(int(time.time()))
    nonce = secrets.token_urlsafe(24)
    message = timestamp.encode() + b"." + nonce.encode() + b"." + body
    signature = hmac.new(os.environ["AFLINK_SIGNING_SECRET"].encode(), message, hashlib.sha256).hexdigest()
    request = urllib.request.Request(
        "https://api.aflink.dev/v1/events", data=body, method="POST",
        headers={
            "Content-Type": "application/json",
            "Authorization": "Bearer " + os.environ["AFLINK_API_KEY"],
            "Idempotency-Key": idempotency_key,
            "X-Aflink-Timestamp": timestamp,
            "X-Aflink-Nonce": nonce,
            "X-Aflink-Signature": "v1=" + signature,
        })
    return urllib.request.urlopen(request, timeout=10)

Go

func sendAflinkEvent(ctx context.Context, idempotencyKey string, body []byte) (*http.Response, error) {
	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
	raw := make([]byte, 24)
	if _, err := rand.Read(raw); err != nil {
		return nil, err
	}
	nonce := base64.RawURLEncoding.EncodeToString(raw)
	mac := hmac.New(sha256.New, []byte(os.Getenv("AFLINK_SIGNING_SECRET")))
	mac.Write([]byte(timestamp + "." + nonce + "."))
	mac.Write(body)
	req, err := http.NewRequestWithContext(ctx, "POST", "https://api.aflink.dev/v1/events", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+os.Getenv("AFLINK_API_KEY"))
	req.Header.Set("Idempotency-Key", idempotencyKey)
	req.Header.Set("X-Aflink-Timestamp", timestamp)
	req.Header.Set("X-Aflink-Nonce", nonce)
	req.Header.Set("X-Aflink-Signature", "v1="+hex.EncodeToString(mac.Sum(nil)))
	return (&http.Client{Timeout: 10 * time.Second}).Do(req)
}

PHP

function send_aflink_event(string $idempotencyKey, string $body) {
    $timestamp = (string) time();
    $nonce = rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '=');
    $signature = hash_hmac('sha256', $timestamp . '.' . $nonce . '.' . $body, getenv('AFLINK_SIGNING_SECRET'));
    $ch = curl_init('https://api.aflink.dev/v1/events');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . getenv('AFLINK_API_KEY'),
            'Idempotency-Key: ' . $idempotencyKey,
            'X-Aflink-Timestamp: ' . $timestamp,
            'X-Aflink-Nonce: ' . $nonce,
            'X-Aflink-Signature: v1=' . $signature,
        ],
    ]);
    $response = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    return [$status, $response];
}

4. Deliver without losing data or slowing your system

Never call Aflink inside the payment request. Use an outbox:

  1. In the same transaction that stores the payment in your database, store the event in an outbox table with the Idempotency-Key and the exact JSON body that will be sent.
  2. A background job sends pending rows. Each attempt uses a new timestamp, nonce and signature, but keeps the same key and the same body.
  3. Mark the row delivered on 202 (accepted) or 200 (already received).
CREATE TABLE aflink_outbox (
  idempotency_key text PRIMARY KEY,
  body            text NOT NULL,          -- exact bytes; never re-serialize
  attempts        int  NOT NULL DEFAULT 0,
  next_attempt_at timestamptz NOT NULL DEFAULT now(),
  delivered_at    timestamptz,
  last_error      text
);
-- job: SELECT … WHERE delivered_at IS NULL AND next_attempt_at <= now()
--      ORDER BY next_attempt_at LIMIT 50 FOR UPDATE SKIP LOCKED

Resending is always safe: the same key with the same body is deduplicated, and the same externalPaymentId never produces two commissions.

ResponseWhat to do
202 or 200Delivered. Processing continues in Aflink.
409 request_replayRepeated nonce. Retry with a new nonce.
429 rate_limitedWait for Retry-After (60 s). Limit: 300 events/min per source.
5xx, timeout or network errorRetry with increasing delay (1, 5, 15, 60 min… for up to 3 days).
400 invalid_integration_headersFix the clock, nonce or idempotency key and resend.
400 invalid_eventInvalid data. Do not repeat; fix the event and alert.
401 invalid_credential / invalid_signatureRevoked key or wrong secret. Pause sending and alert.
409 idempotency_conflictSame key with a different body: a bug in your integration. Do not repeat.
413 event_too_largeBody over 64 KB. Drop unnecessary metadata.

5. Test and go live

  1. Configure the TEST credential and send a subscription.started using a real token (click a test link of your program). TEST events never create commissions.
  2. In Integrations → Event receipts, check the status:
    • PROCESSED: all good.
    • FAILED with processing_requirements_not_met: the event does not match a valid attribution. Check the token, attribution period, currency, that the program is active and that the affiliate is approved.
    • RECEIVED with original_payment_pending: the refund arrived before its original payment; Aflink retries for about 10 minutes.
  3. Switch to the LIVE credential.

Production checklist

Service-month programs (advanced)

If the program pays commission per service months (for example, during the first year), every subscription event needs a serviceWindow:

"serviceWindow": {
  "start": "2026-10-22T14:30:00Z",
  "end": "2027-10-22T14:30:00Z",
  "components": [
    { "id": "annual-plan", "start": "2026-10-22T14:30:00Z", "end": "2027-10-22T14:30:00Z", "amountMinor": 561600 }
  ]
}

Prefer the monthly payments model when you do not need this precision.

Reference

Go to Aflink