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.

Embed
Preview of the card
<a href="https://referalli.com/docs/auth-platforms"><img src="https://referalli.com/docs/_api/public/orgs/07f432f4-34b9-46a7-aaad-c0e395d2e4f9/spaces/2bf4f614-3630-4fbf-85f9-5ecafa6b7d97/pages/f6e7ad48-a302-41da-8f5f-6aa2251b0f40/social-card.svg?v=3e7ae33eec8b" alt="Auth platforms" width="600" height="315"></a>

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

custom (your own auth, or any provider you call from) and atlas (built in)

clerk, supabase, workos and generic

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

custom / atlas

clerk

supabase

workos

generic

Attribute a signup (code, click, anon id, coupon)

✓ signups

✓ user.created

✓ INSERT

✓ user.created

✓ user.created

Referral dated by signup time

✓ signedUpAt

✓ created_at

✓ created_at

✓ created_at

✓ createdAt

autoEnrollUsers (every user becomes a partner)

✓

✓

✓

✓

✓

The user picks their own code

✓ referralCode

Email and name changes reach the partner

✓ users

✓ user.updated

✓ UPDATE

✓ user.updated

✓ user.updated

A ban suspends the partner, an unban lifts it

✓ signals or users

✓ banned on user.updated

✓ banned_until on UPDATE

✗ not in WorkOS's user events

✓ user.banned / user.unbanned

Deletion erases the customer and partner

✓ erase

✓ user.deleted

✓ DELETE

✓ user.deleted (not dsync.user.deleted)

✓ user.deleted

Confirmed fraud voids referrals and claws back commissions

✓ signals

A held referral is released when the email is verified

✓ signals

Data export

✓ export

User id changes (aliases)

✓ aliases

Risk signals

Full risk object

Email verification, plus referally_risk

Email confirmation, plus referally_risk

Email verification, plus referally_risk

referally_risk

Referalli's disposable-email check

✓

✓

✓

✓

✓

Cookie ids only used with consent

✓ 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 (svix-*), 5 min

Standard Webhooks (webhook-*), 5 min

WorkOS-Signature, 5 min

HMAC of the body, no timestamp

Rate limits with RateLimit-* headers, bad-signature lockout

✓

Secret rotation with a 24 h overlap

✓

✓

✓

✓

✓

Connection test

✓ ping

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

provider

custom for anything you sign yourself, or atlas.

programId

The program new users are attributed in.

name

Optional label.

instanceId

Required for atlas (the Atlas instance; one connection per instance per org, 409 otherwise). Optional for custom, as your own label for the auth system.

autoEnrollUsers

Default false. When true, every new user also becomes a partner in programId, with externalRef set to their customer id, plus a coupon and a link.

destinationUrl

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

t

Unix seconds. More than 5 minutes old (or ahead) fails with 401.

v2

"{t}.{METHOD}.{path?query}.{raw body}"

Use this. path?query is the request path exactly as sent, query string included (for example /v1/auth/connections/acon_…/export?customerRef=usr_1). The body is empty for GET. Binding the method and path means a captured request can't be re-sent to another endpoint.

v1

"{t}.{raw body}", or "{t}.{raw query string}" for GET

Deprecated. Accepted until the connection first sees a valid v2 call, then refused (see below).

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

error.code

Meaning

Retry?

400

validation_error

Missing or malformed x-referally-id, or a bad value

No: fix the request

401

unauthorized

Unsigned, stale, wrong signature, or v1 where v2 is required

No (check the secret and clock)

404

not_found

No such connection, or it's a webhook connector

No

409

replay

This request id or signature was already received

No: the earlier attempt arrived

422

validation_error

An undocumented body key, named in the message

No

423

connection_paused

The merchant paused the connection

Later, if it's resumed

429

rate_limited

Over the limit

Yes, after Retry-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

customerRef

yes

See Identity above.

signedUpAt

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 400.

email / emailHash

no

email is hashed on Referalli's side when emailHash is absent. Used for self-referral checks and, with autoEnrollUsers, as the partner's email.

ip, ua

no

The user's, for the fingerprint fallback.

emailDomain

no

The email's domain, when you send emailHash without email. Lets Referalli hold burner-email sign-ups even when it never sees the address. With email, the domain is taken from it.

referral.code

no

From ?ref=, ?via= or ?aff=, or the referally_ctx cookie. Tried as a coupon, then as a link code, in any case. A code that matches neither is reported in reasoning.notes.

referral.clickId, referral.anonId

no

From the referally_ctx cookie. Only used when consent is "granted".

referral.consent

with referral

"granted" or "not_required".

referralCode

no

The new user's own code. With autoEnrollUsers it becomes their coupon and link code if it's free in the org; otherwise an 8-character code is generated.

autoEnroll

no

Overrides the connection's autoEnrollUsers for this call only. false means "don't make this user a referrer"; true enrols them even on a connection that doesn't enrol by default.

risk

no

What your auth system knows about the user; see Risk below.

test

no

true simulates the signup: it is answered exactly like a real one but nothing is stored. See Simulating a sign-up.

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"
  }
}
  • referral is null when nobody earned the user. reasoning.notes then says why, when that's known: an unknown code, cookie ids ignored without consent, a banned partner, or a self-referral. reasoning.codes gives the same reasons as stable codes (see Reason codes).

  • partner appears when the user is enrolled (autoEnrollUsers, or autoEnroll: true on the call). It is created after attribution, so a new user is never credited for themselves.

  • partner.link is 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/links returns as shortUrl. It uses the link's own code (link codes are stored lowercase; they match in any case). It is null when the partner has only a coupon, for example when the connection has no destinationUrl.

  • A user who is already a partner under this customerRef is never attributed to themselves. One whose email matches the partner's is held as pending_review, or rejected under the program's selfReferralPolicy: "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 signedUpAt and 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

consent_missing

clickId / anonId were sent without consent: "granted" and were ignored.

unknown_code

referral.code matches no coupon or link in the program.

customer_is_partner

The would-be referrer is this user themselves (their partner externalRef is this customerRef); they're never credited for themselves.

self_referral

The user's email matches the referrer's and the program's selfReferralPolicy is "reject". (Under the default "review" the referral is created as pending_review instead, with no note.)

banned_partner

The referrer is banned by the merchant; their touches were skipped.

archived_partner

The referrer is archived; their touches were skipped.

flagged_partner

The referrer's account is flagged (banned) by the platform; their touches were skipped.

fingerprint_ignored

The only match was by IP and user agent, and the program's probabilisticPolicy is "off".

risk_hold

The referral was held as pending_review on the risk verdict.

no_email_for_enroll

The user would be enrolled, but the call carried no email, so no partner was created.

enroll_email_taken

Not enrolled: another partner with this email already has a different externalRef.

enroll_code_unavailable

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: false for one that exists only in this answer (looking it up returns 404, and the real call later issues a different id and, unless you send referralCode, a different generated code), true for 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

botScore

0 (human) to 1 (certainly a bot).

disposableEmail

The email's domain is a known disposable-mail provider.

emailVerified

The user has proven they own the email.

selfReferral

The provider links this user to the referrer (same device, payment method and so on).

velocity

Sign-ups from the same IP or device in the last 24 hours, this one included.

reasons

The provider's own reason codes, kept as evidence.

score

The provider's overall risk score for the sign-up, 0 (safe) to 1 (certainly fraudulent).

level

The provider's risk level: low, medium or high (any case).

dropped

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

email, name

Update the partner's email and display name. 409 if another partner in the org has that email.

status: "suspended"

Suspends an active partner (their earnings are held, not lost).

status: "active"

Lifts a suspension made through this provider. A partner the merchant suspended stays suspended, with a note saying so.

status: "deleted"

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" } }

signal

Effect

fraud_confirmed

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 identity_risk case per referral and fires referral.voided. If the user is also a partner, they're suspended.

banned

Suspends the user's own partner record (their earnings are held).

unbanned

Lifts a suspension made through this provider; a merchant's suspension stays, with a note.

email_verified

Releases a referral that was held only because the email wasn't verified.

self_referral

Found after sign-up (same device or payment method as the referrer, say): holds the customer's active referrals for review with an identity_risk case and fires referral.held, in programs whose risk policy holds self-referrals (the default).

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

provider

clerk, supabase, workos or generic.

programId

The program new users are attributed in, unless the metadata names another.

secret

The platform's webhook signing secret (at least 8 characters). Omit it to have one generated.

keys

Optional. Override the metadata key names shown in metadataKeys.

autoEnrollUsers, destinationUrl

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

clerk

user.created

user.updated: primary email, first_name + last_name

banned: true on user.updated suspends; false lifts

user.deleted

supabase

INSERT on auth.users

UPDATE: email, full_name / name in user metadata

banned_until in the future suspends; cleared lifts

DELETE (from old_record.id)

workos

user.created

user.updated: email, first_name + last_name

not sent by WorkOS

user.deleted. dsync.user.deleted is acknowledged but ignored: it names a directory user, not a user id, and people are never matched by email

generic

user.created (the default type)

user.updated

user.banned, user.unbanned

user.deleted

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:

  • customerRef set to the platform's user id,

  • anonId, referralToken and couponCode from 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 verification.status.

Supabase

Email confirmation, from email_confirmed_at.

WorkOS

Email verification, from email_verified.

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

clerk

Standard Webhooks headers svix-id, svix-timestamp, svix-signature. A whsec_ secret is base64-decoded first.

data.id, primary email, names, created_at, banned, merged public_metadata, unsafe_metadata and private_metadata.

supabase

Standard Webhooks headers webhook-id, webhook-timestamp, webhook-signature.

record (or user; old_record on DELETE): id, email, created_at, banned_until, raw_user_meta_data or user_metadata.

workos

WorkOS-Signature: t=…, v1=…, hex HMAC-SHA256 of {t}.{body}; t is in milliseconds.

data.id, data.email, names, created_at, data.metadata.

generic

x-webhook-signature, hex HMAC-SHA256 of the body.

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).

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.

Updated