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:
- 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_…. - Capture — your site stores that token with the customer's signup.
- 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.
0. Set up in Aflink (5 minutes)
- Sign in at aflink.app with your CIATTO account and open your company.
- 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.
- 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.
Option A — on the server (recommended)
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 SaaS | Event | Required fields besides the common ones |
|---|---|---|
| First payment of a subscription | subscription.started | attributionToken (or couponCode), externalSubscriptionId, customerExternalId, occurredAt |
| Each later subscription payment | subscription.renewed | externalSubscriptionId, customerExternalId, subscriptionCycle (2, 3, …), occurredAt |
| One-time purchase | order.paid | attributionToken (or couponCode), customerExternalId |
| Full or partial refund | refund.created | originalExternalPaymentId |
| Chargeback | chargeback.created | originalExternalPaymentId |
Fields common to every event:
| Field | Rule |
|---|---|
externalPaymentId | Payment ID in your system or gateway (3–255 characters). Unique per payment. |
customerEmail | Customer email. |
customerExternalId | Stable customer ID in your SaaS, never the email (up to 255). E.g. yoursaas:account_123. |
value | Amount the commission is calculated on, as decimal text: "189.00". Always positive, refunds included (up to 2 decimals). |
currency | 3-letter code equal to the program's, e.g. BRL. |
occurredAt | Payment time in RFC 3339 (UTC). At most 5 minutes in the future. |
metadata | Optional. 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:
| Header | Value |
|---|---|
Authorization | Bearer <API key> |
Idempotency-Key | Deterministic event key, e.g. subscription.started:in_1Q2w3E (8–255). |
X-Aflink-Timestamp | Unix seconds (10 digits). Clock within 5 minutes. |
X-Aflink-Nonce | Fresh random value on every attempt, 16–128 characters [A-Za-z0-9_-]. |
X-Aflink-Signature | v1= + 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:
- In the same transaction that stores the payment in your database, store the event in an outbox table with the
Idempotency-Keyand the exact JSON body that will be sent. - A background job sends pending rows. Each attempt uses a new timestamp, nonce and signature, but keeps the same key and the same body.
- Mark the row delivered on
202(accepted) or200(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.
| Response | What to do |
|---|---|
202 or 200 | Delivered. Processing continues in Aflink. |
409 request_replay | Repeated nonce. Retry with a new nonce. |
429 rate_limited | Wait for Retry-After (60 s). Limit: 300 events/min per source. |
5xx, timeout or network error | Retry with increasing delay (1, 5, 15, 60 min… for up to 3 days). |
400 invalid_integration_headers | Fix the clock, nonce or idempotency key and resend. |
400 invalid_event | Invalid data. Do not repeat; fix the event and alert. |
401 invalid_credential / invalid_signature | Revoked key or wrong secret. Pause sending and alert. |
409 idempotency_conflict | Same key with a different body: a bug in your integration. Do not repeat. |
413 event_too_large | Body over 64 KB. Drop unnecessary metadata. |
5. Test and go live
- Configure the TEST credential and send a
subscription.startedusing a real token (click a test link of your program). TEST events never create commissions. - In Integrations → Event receipts, check the status:
PROCESSED: all good.FAILEDwithprocessing_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.RECEIVEDwithoriginal_payment_pending: the refund arrived before its original payment; Aflink retries for about 10 minutes.
- Switch to the LIVE credential.
Production checklist
- LIVE key and secret in the server's secret store; nothing in the front end.
-
_aflinktoken captured and stored on the customer record. - Events written to the outbox in the payment's transaction.
- Deterministic
Idempotency-Keyand exact body reused on retries. - Only referred customers generate events.
- Stable
customerExternalIdand currency equal to the program's. - Refunds and chargebacks sent with the original payment.
- Alerts for
400,401and409 idempotency_conflictresponses.
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 }
]
}
startis the beginning of the customer's first paid service and never changes on renewals.endis exactlystartplus the program duration in months, keeping the time and using the last day of the month when the day does not exist.- Components are the billed periods (each up to 12 months), in whole seconds. Their prorated sum inside the window must equal
value(in cents).
Prefer the monthly payments model when you do not need this precision.
Reference
- Full contract (OpenAPI 3.1):
api/openapi.jsonin the Aflink repository. - Questions: [email protected].