Auth platforms
Attribute signups, enrol users as partners and follow them through updates, bans and deletion, from any auth provider: your own, Atlas, Clerk, Supabase, WorkOS or anything that can send a webhook.
Referalli doesn't depend on any particular auth provider. Whatever creates your users can tell Referalli about them, and the same things happen:
A new user is attributed to whoever referred them, dated by when they signed up, so a late call loses nothing.
With
autoEnrollUsers, every new user also becomes a partner with their own coupon and link.Later changes follow them. An email or name change updates their partner record, a ban suspends it, and deleting the user erases them as a customer and as a partner.
Risky signups are held for review using the program's
riskPolicy.
There are two ways to connect:
Signed protocol | Webhook connector | |
|---|---|---|
Providers |
|
|
Who calls whom | Your auth server calls Referalli while it creates the user | The platform's own user webhooks are pointed at Referalli |
Best for | Your own auth, any provider with server-side hooks, and full control | Hosted platforms where you'd rather not write code |
Capability matrix
Capability |
|
|
|
|
|
|---|---|---|---|---|---|
Attribute a signup (code, click, anon id, coupon) | ✓ | ✓ | ✓ | ✓ | ✓ |
Referral dated by signup time | ✓ | ✓ | ✓ | ✓ | ✓ |
| ✓ | ✓ | ✓ | ✓ | ✓ |
The user picks their own code | ✓ | ||||
Email and name changes reach the partner | ✓ | ✓ | ✓ | ✓ | ✓ |
A ban suspends the partner, an unban lifts it | ✓ | ✓ | ✓ | ✗ not in WorkOS's user events | ✓ |
Deletion erases the customer and partner | ✓ | ✓ | ✓ | ✓ | ✓ |
Confirmed fraud voids referrals and claws back commissions | ✓ | ||||
A held referral is released when the email is verified | ✓ | ||||
Data export | ✓ | ||||
User id changes (aliases) | ✓ | ||||
Risk signals | Full | Email verification, plus | Email confirmation, plus | Email verification, plus |
|
Referalli's disposable-email check | ✓ | ✓ | ✓ | ✓ | ✓ |
Cookie ids only used with consent | ✓ | You decide what goes in the metadata | same | same | same |
Signature | HMAC v2 (binds the method and path), single-use request ids, 5 min freshness | Standard Webhooks ( | Standard Webhooks ( |
| HMAC of the body, no timestamp |
Rate limits with | ✓ | ||||
Secret rotation with a 24 h overlap | ✓ | ✓ | ✓ | ✓ | ✓ |
Connection test | ✓ |
Rows the webhook connectors lack are the ones the platform's webhooks don't carry. A WorkOS ban, for example, simply never arrives as an event. When you need them, call the signed protocol from your own server alongside the connector. Everything is keyed by the same customerRef, the platform's user id.
The signed protocol
The protocol is a handful of signed HTTPS endpoints that your auth server calls. Any auth system can speak it: your own login, Auth0 Actions, Firebase Auth triggers, Cognito Lambda triggers, Keycloak event listeners, Ory or Stytch webhooks relayed through your server, and so on. Atlas speaks it natively (see Atlas).
Connect
curl -X POST https://referalli.com/v1/auth/connections \
-H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{
"provider": "custom",
"programId": "prg_...",
"name": "Our auth",
"autoEnrollUsers": true,
"destinationUrl": "https://yourapp.com/signup"
}'{
"id": "acon_01J...",
"provider": "custom",
"secret": "whsec_...",
"programId": "prg_...",
"instanceId": null,
"autoEnrollUsers": true,
"destinationUrl": "https://yourapp.com/signup",
"signupsUrl": "/v1/auth/connections/acon_01J.../signups",
"eraseUrl": "/v1/auth/connections/acon_01J.../erase",
"exportUrl": "/v1/auth/connections/acon_01J.../export"
}Field | Meaning |
|---|---|
|
|
| The program new users are attributed in. |
| Optional label. |
| Required for |
| Default |
| Where those partners' links send visitors. Without it, auto-enrolled partners get a coupon but no link. |
The secret is returned only here; keep it on your server. In the dashboard, the Auth platform card in Settings creates the same connection.
To pause a connection (its calls then get 404), resume it, rename it, or change autoEnrollUsers or destinationUrl, send PATCH /v1/auth/connections/{id} with any of status (active or paused), name, autoEnrollUsers and destinationUrl. Owners and admins only; every change is audited.
To remove one for good, DELETE /v1/auth/connections/{id}: it leaves GET /v1/auth/connections, every endpoint and webhook URL for it answers 404, and its secrets are wiped. Referrals it already attributed stay. Deleting can't be undone; pause instead if you might need it again.
Identity
customerRef is your user id, the one your billing reports payments under. Referrals, and later payments, are filed under it, and auto-enrolled partners carry it as externalRef. If billing knows the user by another id (Stripe's cus_…), link the two with referally_customer_ref (see Link billing customers).
Signing
Every call carries x-referally-signature with a timestamp and one or two signatures, each a hex HMAC-SHA256 keyed with the connection secret (the whole string, as UTF-8):
Part | Signs | Notes |
|---|---|---|
| Unix seconds. More than 5 minutes old (or ahead) fails with | |
|
| Use this. |
|
| Deprecated. Accepted until the connection first sees a valid |
With v2, also send x-referally-id: a unique id for the attempt (1–128 characters of A-Z a-z 0-9 _ . : -). Each id, and each v2 signature, is accepted once in a 20-minute window; a repeat returns 409 with error.code: "replay". Use a fresh id and a fresh signature for every attempt, including retries (every handler is idempotent, so a retry is always safe). A v2 call without an id fails with 400.
Node:
import { createHmac, randomUUID } from 'node:crypto';
const BASE = 'https://referalli.com';
async function referally(method: 'GET' | 'POST', path: string, payload?: unknown) {
const body = payload === undefined ? '' : JSON.stringify(payload);
const t = Math.floor(Date.now() / 1000);
const v2 = createHmac('sha256', process.env.REFERALLY_AUTH_SECRET!)
.update(`${t}.${method}.${path}.${body}`)
.digest('hex');
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
'content-type': 'application/json',
'x-referally-signature': `t=${t},v2=${v2}`,
'x-referally-id': randomUUID(),
},
body: method === 'GET' ? undefined : body,
});
if (!res.ok) throw new Error(`Referalli ${res.status}: ${await res.text()}`);
return res.json();
}
// In your sign-up handler, after creating the user:
await referally('POST', `/v1/auth/connections/${connectionId}/signups`, {
customerRef: user.id,
email: user.email,
signedUpAt: user.createdAt.toISOString(),
referral: { code: req.cookies.ref ?? null, consent: 'not_required' },
});Python:
import hashlib, hmac, json, os, time, uuid
import requests
BASE = "https://referalli.com"
def referally(method: str, path: str, payload=None):
body = "" if payload is None else json.dumps(payload, separators=(",", ":"))
t = int(time.time())
v2 = hmac.new(
os.environ["REFERALLY_AUTH_SECRET"].encode(),
f"{t}.{method}.{path}.{body}".encode(),
hashlib.sha256,
).hexdigest()
res = requests.request(
method,
BASE + path,
data=body.encode() if method != "GET" else None,
headers={
"content-type": "application/json",
"x-referally-signature": f"t={t},v2={v2}",
"x-referally-id": str(uuid.uuid4()),
},
)
res.raise_for_status()
return res.json()
referally("POST", f"/v1/auth/connections/{connection_id}/users",
{"customerRef": user.id, "email": user.email})Sign the exact bytes you send. Serialise the body once and pass that string to both the signature and the request.
Moving from v1 to v2. If you started with v1, send v1 and v2 together during the switch. The first valid v2 call makes the connection require v2 from then on, so a captured request with its v2 stripped can't fall back to v1. You can also require v2 up front with PATCH /v1/auth/connections/{id} { "signatureVersion": "v2" }, or go back to accepting both (which clears the sticky requirement) with "v1+v2". GET /v1/auth/connections shows signatureVersion and requiresV2. A v2 that doesn't verify never falls back to v1.
Strict bodies. The signed endpoints reject a key they don't document with 422 validation_error naming it (unknown field(s): …). The check runs after the signature, so an unsigned call learns nothing.
Limits. Each connection takes bursts of up to 300 calls, refilling at 20 a second. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until the bucket is full); a 429 rate_limited adds Retry-After (seconds). Wait exactly that long. An address that sends 20 bad signatures (or unknown connection ids) is locked out with 429 for a few minutes, even for correctly signed calls, so a leaked connection id can't be used to guess the secret. Every response also carries Date, so you can check clock skew without a ping.
Errors.
Status |
| Meaning | Retry? |
|---|---|---|---|
|
| Missing or malformed | No: fix the request |
|
| Unsigned, stale, wrong signature, or | No (check the secret and clock) |
|
| No such connection, or it's a webhook connector | No |
|
| This request id or signature was already received | No: the earlier attempt arrived |
|
| An undocumented body key, named in the message | No |
|
| The merchant paused the connection | Later, if it's resumed |
|
| Over the limit | Yes, after |
Nulls and missing endpoints
On the signed endpoints, null means "not sent": send null for any value you don't have and it is treated as absent (a referral whose only value is code is attributed by that code; a null consent counts as not_required, so cookie ids are ignored). Keys the endpoint doesn't define are still refused with 422.
A path that isn't one of these endpoints answers 404 with error.code: "not_found" and error.details.endpoints listing the real ones, never 401. A 401 always means the signature itself was rejected.
Attribute a signup
POST /v1/auth/connections/{id}/signups
{
"customerRef": "usr_8812",
"email": "new@example.com",
"ip": "203.0.113.7",
"ua": "Mozilla/5.0 ...",
"signedUpAt": "2026-10-21T14:03:11Z",
"referral": {
"code": "WJXQGLBW",
"clickId": "clk_...",
"anonId": "a_...",
"capturedAt": "2026-10-21T14:01:00Z",
"consent": "granted"
},
"referralCode": "K7Q2MZ4P"
}Field | Required | Description |
|---|---|---|
| yes | See Identity above. |
| yes | When the user was created. The referral is dated by it, so a call retried after an outage loses nothing. More than 5 minutes in the future returns |
| no |
|
| no | The user's, for the fingerprint fallback. |
| no | The email's domain, when you send |
| no | From |
| no | From the |
| with |
|
| no | The new user's own code. With |
| no | Overrides the connection's |
| no | What your auth system knows about the user; see Risk below. |
| no |
|
Response:
{
"referral": {
"id": "ref_...",
"partnerId": "ptr_...",
"partnerExternalRef": "usr_77",
"matchedVia": "coupon",
"status": "active"
},
"partner": {
"id": "ptr_...",
"code": "K7Q2MZ4P",
"link": "https://go.yourapp.com/go/tt_.../k7q2mz4p"
}
}referralisnullwhen nobody earned the user.reasoning.notesthen says why, when that's known: an unknown code, cookie ids ignored without consent, a banned partner, or a self-referral.reasoning.codesgives the same reasons as stable codes (see Reason codes).partnerappears when the user is enrolled (autoEnrollUsers, orautoEnroll: trueon the call). It is created after attribution, so a new user is never credited for themselves.partner.linkis the partner's absolute tracked link,/go/{trackingToken}/{code}, on your verified link domain when you have one (otherwise on Referalli's own address), the same URL/v1/linksreturns asshortUrl. It uses the link's own code (link codes are stored lowercase; they match in any case). It isnullwhen the partner has only a coupon, for example when the connection has nodestinationUrl.A user who is already a partner under this
customerRefis never attributed to themselves. One whose email matches the partner's is held aspending_review, or rejected under the program'sselfReferralPolicy: "reject".The call is idempotent on
customerRef: repeating it returns the same referral and partner, and the first attribution wins.Payments already reported for the user between
signedUpAtand the call earn as if the call had been on time.
Reason codes
reasoning is present when there is something to explain. reasoning.notes is English for people and its wording may change; reasoning.codes is for code: each distinct reason appears once, in the order of its first note, and a code never changes meaning. New codes may be added, so treat an unknown one as informational.
"reasoning": {
"notes": ["clickId/anonId ignored: cookie consent was not granted",
"referral code \"SUMMER\" matches no coupon or link in this program"],
"codes": ["consent_missing", "unknown_code"]
}Code | Meaning |
|---|---|
|
|
|
|
| The would-be referrer is this user themselves (their partner |
| The user's email matches the referrer's and the program's |
| The referrer is banned by the merchant; their touches were skipped. |
| The referrer is archived; their touches were skipped. |
| The referrer's account is flagged (banned) by the platform; their touches were skipped. |
| The only match was by IP and user agent, and the program's |
| The referral was held as |
| The user would be enrolled, but the call carried no |
| Not enrolled: another partner with this email already has a different |
| Not enrolled: no free referral code could be allocated. |
Simulating a sign-up
Send "test": true in the body, or the header atlas-test: 1 (Atlas's "Simulate a sign-up" uses the header; any caller may), to see what a signup would do without doing it. The call is signed, validated, rate-limited and matched exactly like a real one, and the answer has the same referral, partner (with link), reasoning.notes and reasoning.codes, plus "test": true. Nothing is stored: no referral, association, click, identity link, partner, enrolment, coupon, link, fraud case, event, webhook or audit entry.
{
"test": true,
"referral": { "id": "ref_...", "partnerId": "ptr_...", "partnerExternalRef": null,
"matchedVia": "deterministic", "status": "active", "persisted": false },
"partner": { "id": "ptr_...", "code": "K7Q2MZ4P", "link": "https://.../go/tt_.../k7q2mz4p", "persisted": false }
}Ids are returned, and each object carries
persisted:falsefor one that exists only in this answer (looking it up returns404, and the real call later issues a different id and, unless you sendreferralCode, a different generated code),truefor one that already existed, such as a referral from an earlier real signup or the user's existing partner record.A real call afterwards for the same user, even with the same
x-referally-id, is processed normally: a test call's request id is tracked apart from real ones (it is still single-use among test calls, and every signature is single-use).Test calls count toward the rate limit.
Risk
Send what your auth system knows about a new user as risk on /signups. Every field is optional:
"risk": { "botScore": 0.92, "disposableEmail": false, "emailVerified": true,
"selfReferral": false, "velocity": 3, "reasons": ["headless_browser"] }Field | Meaning |
|---|---|
| 0 (human) to 1 (certainly a bot). |
| The email's domain is a known disposable-mail provider. |
| The user has proven they own the email. |
| The provider links this user to the referrer (same device, payment method and so on). |
| Sign-ups from the same IP or device in the last 24 hours, this one included. |
| The provider's own reason codes, kept as evidence. |
| The provider's overall risk score for the sign-up, 0 (safe) to 1 (certainly fraudulent). |
| The provider's risk level: |
| The provider dropped (blocked) this sign-up as high risk. Always held, whatever the policy. |
Common provider names are accepted too: risk_score, risk_level, risk_signals (added to reasons), outcome: "blocked" (same as dropped), dropped_as_high_risk, and snake_case forms of the fields above. Risk fields Referalli doesn't know are ignored, so a new field never fails a sign-up. When you don't say, Referalli checks the email's domain against known disposable-mail providers itself.
When the signup is attributed and the program's riskPolicy says so, the referral is held as pending_review (no commissions until a person approves it) and an identity_risk fraud case opens with the evidence, firing fraud.case_opened. The response says "status": "pending_review" with a reasoning.notes entry naming the reasons. Flagged customers also lower the referring partner's quality score.
The policy is a program setting (PATCH /v1/programs/{id} with riskPolicy), shown with its defaults:
"riskPolicy": { "holdBotScore": 0.8, "holdRiskScore": 0.8, "holdHighRiskLevel": true,
"holdDisposableEmail": true, "holdVelocity": 10,
"holdUnverifiedEmail": false, "holdSelfReferral": true }When a user changes
POST /v1/auth/connections/{id}/users with { "customerRef", "email"?, "name"?, "status"? } keeps the auto-enrolled partner in step:
Field | Effect |
|---|---|
| Update the partner's email and display name. |
| Suspends an active partner (their earnings are held, not lost). |
| Lifts a suspension made through this provider. A partner the merchant suspended stays suspended, with a note saying so. |
| The same as erase, below. |
The response is { "ok": true, "partner": { "id", "status" }, "updated": ["email", ...], "notes"?: [...] }, or "partner": null with a note when no partner has that externalRef.
Signals
POST /v1/auth/connections/{id}/signals reports a later verdict about a user:
{ "customerRef": "usr_8812", "signal": "fraud_confirmed", "at": "2026-10-22T09:00:00Z",
"details": { "reason": "chargeback ring" } }
| Effect |
|---|---|
| Voids the customer's referrals and claws back every commission line they earned that isn't paid yet (ledger reversals; paid lines stay for the merchant to recover). Opens an actioned |
| Suspends the user's own partner record (their earnings are held). |
| Lifts a suspension made through this provider; a merchant's suspension stays, with a note. |
| Releases a referral that was held only because the email wasn't verified. |
| Found after sign-up (same device or payment method as the referrer, say): holds the customer's active referrals for review with an |
The response is { "ok": true, "signal", "referrals": [{ "id", "status" }], "reversed": [{ "lineId", "amountMinor", "currency" }], "partner": { "id", "status" } | null, "notes"?: [...] }.
When the app changes a user's id
If your app changes the user's id after the signup call, send:
POST /v1/auth/connections/{id}/aliases with { "fromCustomerRef": "<old id>", "toCustomerRef": "<new id>" }
The referral (in every program) and the auto-enrolled partner's externalRef move to the new id, and an alias is recorded. Afterwards billing events, associate calls, retried signups, erasure and export under either id resolve to the same referral; webhooks use the new id. Payments reported before the alias under whichever id had no referral (the old id when the referral was already filed under the new one, or the new id while the referral was still under the old one) matched nothing when they arrived; the alias reprocesses them through normal commission processing, so they earn and emit commission.earned as if the alias had always existed. The usual attribution rules still decide: only payments inside the referral's window (from 5 minutes before its signup to its signup-to-conversion expiry) earn, and a payment already matched to the referral is never paid again. The response is { "ok": true, "customerRef": "<new id>", "referralIds": [...], "partnerId": "ptr_..." | null, "alreadyAliased": false, "reprocessedPayments": 1 }, where reprocessedPayments counts the earlier payments run through commission processing by this call. Repeating it is safe: the same move again is a 200 with alreadyAliased: true and reprocessedPayments: 0. It returns 409 if the new id already has its own referral in the same program, or another partner already has it as externalRef, and 400 if the two ids are the same. A customer.aliased webhook is emitted.
Erasure and access
POST /v1/auth/connections/{id}/erase with { "customerRef": "usr_8812" } pseudonymises the customer and the partner whose externalRef matches, in one call. Ledger totals are kept. It also erases what was filed under any older id the customer was aliased from. The response is { "ok": true, "erased": true, "customer": {...} | null, "partner": {...} | null }. Erasing a customer Referalli never saw is a 200 with "erased": false, so a retry settles cleanly. Afterwards GET /v1/partners?externalRef=usr_8812 returns nothing.
Both follow aliases. GET /v1/auth/connections/{id}/export?customerRef=usr_8812 returns { "customer": {...}, "partner": {...} | null } for access requests. With v2, sign GET, the path with its query and an empty body; with v1, the raw query string customerRef=usr_8812.
Test the connection
POST /v1/auth/connections/{id}/ping with an empty body or {} returns:
{ "ok": true, "connectionId": "acon_...", "programId": "prg_...", "orgName": "Numestri",
"autoEnrollUsers": true, "serverTime": "2026-10-21T14:03:11.000Z" }Compare serverTime with your clock: more than a few minutes apart and signatures start failing.
"Invited by" on the sign-up page
GET /t/{trackingToken}/codes/{code} is public (no signature, cross-origin like the tracking script) and checks a referral code before sign-up, in any case, against coupons and link codes:
{ "valid": true, "partnerDisplayName": "Jamie",
"refereeReward": { "kind": "store_credit", "bps": 200, "capMinor": 500, "currency": "usd", "on": "first_payment" } }partnerDisplayName is the first word of the partner's display name only (never the email). refereeReward is null when the program has none. Unknown codes, and codes of banned, archived or platform-flagged partners, return 404 { "valid": false }. It is rate-limited per address (429).
Atlas
Atlas is a built-in implementation of the signed protocol: connect it once and it makes every call above itself. It calls signups while it creates the user, with the referral it captured at sign-in, and it forwards updates, bans, fraud verdicts, id changes, deletions and data-subject requests. Your app makes no referral calls at all.
Connect it like custom, with "provider": "atlas" and the Atlas instanceId (required). Then enter the connection id and secret in the Atlas instance's referral settings.
customerRef is the Atlas user's external_id when it's set (your own user id), otherwise the Atlas user id. Report billing events with the same id and no referally_customer_ref link is needed. If the app stamps external_id after sign-up, Atlas sends the alias call itself.
Atlas sends its risk verdict in its own field names (risk_score, risk_level, risk_signals, outcome), which the risk object accepts as they are.
Webhook connectors
If your app uses Clerk, Supabase or WorkOS, point the platform's user webhooks at Referalli instead of writing code. generic takes the same events from anything that can sign a webhook.
1. Create a connection
curl -X POST https://referalli.com/v1/auth/connections \
-H "Authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d '{"provider": "clerk", "programId": "prg_...", "secret": "whsec_from_clerk_dashboard", "autoEnrollUsers": true}'{
"id": "acon_01JB...",
"provider": "clerk",
"secret": "whsec_from_clerk_dashboard",
"webhookUrl": "/webhooks/auth/acon_01JB...",
"autoEnrollUsers": true,
"destinationUrl": null,
"metadataKeys": {
"referralToken": "referally_click_id",
"anonId": "referally_anon_id",
"couponCode": "referally_coupon",
"programId": "referally_program_id",
"autoEnroll": "referally_auto_enroll"
}
}Field | Notes |
|---|---|
|
|
| The program new users are attributed in, unless the metadata names another. |
| The platform's webhook signing secret (at least 8 characters). Omit it to have one generated. |
| Optional. Override the metadata key names shown in |
| As for the signed protocol. |
Point the platform's webhook at https://referalli.com/webhooks/auth/{id} and subscribe it to the events in the table below.
2. Put the referral context in signup metadata
On your signup page, with the tracking script loaded, read the context and attach it to the new user using the default key names:
const ref = window.aff.getReferral();
const referallyMetadata = {
referally_anon_id: ref.anonId,
...(ref.clickId ? { referally_click_id: ref.clickId } : {}),
};
// Clerk: pass as unsafeMetadata on sign-up
await signUp.create({ emailAddress, password, unsafeMetadata: referallyMetadata });
// Supabase: pass as user metadata
await supabase.auth.signUp({ email, password, options: { data: referallyMetadata } });Add referally_coupon if the user entered a partner coupon code, and referally_program_id to attribute into a program other than the connection's. referally_auto_enroll: false (or "false") keeps that user from being enrolled as a partner when the connection has autoEnrollUsers on. Because sign-up metadata can be written by the user, it can only opt out: true never enrols anyone on a connection that doesn't enrol by default (use the signed protocol's autoEnroll for that). Whether cookie ids may be stored follows your own consent banner: only put referally_anon_id and referally_click_id in the metadata when the visitor allowed it.
Events
Provider | Created | Updated | Banned | Deleted |
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
| not sent by WorkOS |
|
|
|
|
|
|
For Supabase, add a database webhook on the auth.users table for inserts, updates and deletes. The generic body is:
{ "type": "user.created", "customerRef": "usr_1", "email": "a@b.co", "name": "Ada",
"createdAt": "2026-10-21T14:03:11Z", "metadata": { "referally_coupon": "WJXQGLBW" } }Updates, bans and deletions do what the signed protocol's users, signals and erase do, under the platform's user id. An unban lifts only a suspension made through this provider.
What Referalli does with each signup
For every created user, Referalli reads the metadata keys and attributes the user with:
customerRefset to the platform's user id,anonId,referralTokenandcouponCodefrom metadata,the user's email (hashed for self-referral checks, and used as the partner's email with
autoEnrollUsers),the platform's creation time as the signup time,
an idempotency key per connection and user, so redelivered webhooks are harmless.
The response lists the outcome per user, for example { "customerRef": "user_2ab…", "attributed": true, "partnerId": "ptr_…", "matchedVia": "deterministic", "partner": { "id": "ptr_…", "code": "K7Q2MZ4P" } }. Users with no referral context are reported with "reason": "no referral context" (and are still enrolled with autoEnrollUsers), and updates with "reason": "not a signup".
Risk signals
Each signup's risk feeds the same check as the signed protocol's risk: a risky referral is held as pending_review with an identity_risk fraud case, using the program's riskPolicy. The result then carries "heldForReview": ["disposable_email"].
Platform | What it provides in the signup webhook |
|---|---|
Clerk | Email verification, from the primary address's |
Supabase | Email confirmation, from |
WorkOS | Email verification, from |
All | A disposable-email check on the signup's address, done by Referalli when the platform didn't decide it. |
None of these platforms puts a bot score or velocity in its webhook. If your app has its own fraud verdict (a captcha score, a device check), forward it in the user's metadata as referally_risk, with the same fields as risk: botScore, disposableEmail, emailVerified, selfReferral, velocity, score, level, dropped and reasons. The signed protocol's spellings work too (self_referral: true, risk_signals: ["self_referral"], outcome: "blocked"), and so does a JSON string. A malformed referally_risk is ignored rather than failing the webhook.
// Clerk, in the sign-up form
await signUp.create({
emailAddress,
password,
unsafeMetadata: { referally_anon_id: ref.anonId, referally_risk: { botScore: captchaScore, reasons: ['captcha'] } },
});Because sign-up metadata can be written by the user, a forwarded verdict can only add risk: a higher botScore or velocity, true for disposableEmail or selfReferral, false for emailVerified. It can't clear the platform's email verification or Referalli's disposable-email check. To send a verdict the user can't touch, call the signed protocol from your server.
An unverified email holds the referral only when the program's riskPolicy.holdUnverifiedEmail is on (it's off by default).
Signature verification
Provider | Verification | User fields read |
|---|---|---|
| Standard Webhooks headers |
|
| Standard Webhooks headers |
|
|
|
|
|
| The body above. |
A failed signature returns 401; an unknown or inactive connection returns 404. Deliveries signed more than 5 minutes ago are rejected with 401, except on generic, which carries no timestamp (use the signed protocol when you need replay protection).
Link billing customers to these signups
The referral is filed under the auth user id, but billing providers may pay under their own customer id (cus_… for Stripe). Bridge the two by setting referally_customer_ref to the auth user id in the billing provider's metadata, for example when your server creates the Stripe customer:
await stripe.customers.create({
email: user.email,
metadata: { referally_customer_ref: user.id }, // the auth platform's user id
});Subscription metadata works too. The first Stripe webhook that carries the key, including customer.created, records a link from cus_… to the auth user id. Every later invoice, renewal, refund and cancellation for that customer is then credited to the referral, even when the metadata is not on the event. Paddle, Lemon Squeezy, Polar and the generic API support the same key; see Linking billing customers. Without the link, payments under cus_… find no referral and earn nothing.
Rotate the secret
POST /v1/auth/connections/{id}/rotate-secret (merchant API, owner or admin) returns { "id", "provider", "secret", "previousSecretExpiresAt" }. The old secret keeps verifying for 24 hours, so update your auth system within that window; after it only the new one works. GET /v1/auth/connections shows rotation: { "inProgress": true, "previousSecretExpiresAt": "..." } while both are live. To end the overlap early (say, the old secret leaked), POST /v1/auth/connections/{id}/revoke-previous-secret returns { "ok": true, "revoked": true } and the old secret stops verifying at once. The dashboard shows the expiry with a "Revoke old secret now" button. Rotation works for every auth connection; for providers that issue their own secret (Clerk, Supabase, WorkOS) the webhook receiver accepts both during the overlap.
Auth.js and NextAuth
Callback-based auth libraries run inside your own server, so there is no webhook. The JavaScript SDK ships referallyAuthEvents, which you plug into Auth.js events.createUser. See SDKs. For updates and deletions too, call the signed protocol from the library's other events with a custom connection.
In webhooks
commission.earned, commission.reversed and referee_reward.earned carry customerExternalRef: the customer's id when they came in through an auth connection or are themselves a partner, otherwise null. Commission events also carry partnerExternalRef. You can credit the right accounts without lookups.