Migrating existing referrals
Backfill customers, referral codes, relationships, and historical conversions from a legacy referral table into OfferKit without issuing rewards twice.
If you’re already running a referrals program before adopting OfferKit, you’ll have two kinds of historical data to preserve:
- Referrers who hold a code — each user with a code that may still be shared. Becomes one
referral_coderow. - Past conversions — each successful referral (referrer + referee pair) where the rewards were already issued. Becomes one
referral_conversionrow.
This guide walks the import end-to-end. It’s intentionally explicit about what not to do — the most expensive mistake here is double-issuing rewards.
Decide what you’re preserving
OfferKit needs three things for each historical record:
| Your data | OfferKit field | Notes |
|---|---|---|
| Referrer user id | referral_code.referrer_customer_id | Customer must exist in OfferKit first (see Step 1). |
| Referee user id | referral_conversion.referee_customer_id | Same. |
| Code string | referral_code.code | Must be globally unique inside the OfferKit deployment. |
| Original event id | referral_conversion.conversion_event_id | Optional but strongly recommended — your old order id is the cleanest dedupe key. |
| Issued voucher code(s) | referral_conversion.referrer_outcome.voucherCode / referee_outcome.voucherCode | Only if you still want them to honor the original vouchers (see Step 4). |
Things you don’t need to import: original conversion timestamps (OfferKit uses created_at = now() unless you override), the running program total, or analytics aggregates (OfferKit derives those).
Step 1: import customers
You need an OfferKit customer row for every referrer and referee in your history. The Customer model has an externalId field — pass your user id there and OfferKit handles the rest:
import { createClient } from "@offerkit/sdk";
const offerkit = createClient({
baseUrl: process.env.OFFERKIT_API_URL!,
apiKey: process.env.OFFERKIT_API_KEY!,
});
for (const user of legacyUsers) {
await offerkit.customers.upsert({
externalId: user.id, // your stable user id
email: user.email,
name: user.name,
});
}upsert is idempotent on externalId — safe to re-run after a crash, safe to call repeatedly during normal operation. The response includes { customer, created } so you can fire downstream events on first-create only. For lookups later, call customers.getByExternalId({ params: { externalId: user.id } }).
Step 2 and Step 3 below assume customers have externalId set this way.
Step 2: insert one referral_code per referrer
The contract endpoint is POST /api/v1/referrals/issue. It’s idempotent on (programId, referrerCustomerId) — so running the importer twice is safe; the second pass returns the same code instead of erroring.
But there’s a catch: issue generates the code. If you need to preserve the existing code strings your users have been sharing, you have two options:
Option A (preferred): write directly to the database.
The issue endpoint can’t accept a custom code string, so for a one-time bulk import where preserving the original strings matters, run a SQL script against the OfferKit Postgres directly:
First load your legacy table into the OfferKit database (e.g. legacy_referral(referrer_user_id, code, created_at) via \copy or a one-off import). Then:
INSERT INTO referral_code (id, program_id, referrer_customer_id, code, created_at, updated_at)
SELECT
gen_random_uuid(),
'<your-programId>'::uuid,
c.id,
r.code,
r.created_at,
r.created_at
FROM legacy_referral r
JOIN customer c ON c.external_id = r.referrer_user_id::text AND c.deleted_at IS NULL
ON CONFLICT (program_id, referrer_customer_id) DO NOTHING;code has a global UNIQUE constraint; if your legacy table has duplicates, dedupe first.
Option B: let OfferKit mint new codes.
If your users haven’t shared their codes far and wide yet (or you’re happy to email everyone the new code), call referrals.issue and discard the old strings:
for (const referrer of legacyReferrers) {
const customerId = externalToCustomerId.get(referrer.userId);
if (!customerId) continue;
await offerkit.referrals.issue({
programId: process.env.OFFERKIT_REFERRAL_PROGRAM_ID!,
referrerCustomerId: customerId,
});
}This is simpler and idempotent, but you lose the codes already in the wild.
Step 3: insert one referral_conversion per historical conversion
This is the step where double-issuing is most likely to bite. Do not call referrals.convert for historical rows — that endpoint issues fresh rewards (mints new vouchers, credits loyalty points). For history, you want to record that a conversion happened without firing rewards again.
Same SQL pattern:
INSERT INTO referral_conversion (
id, code_id, referee_customer_id, status, converted_at, conversion_event_id,
referrer_outcome, referee_outcome, created_at, updated_at
)
SELECT
gen_random_uuid(),
rc.id,
c_referee.id,
'converted',
lc.converted_at,
lc.original_order_id, -- your dedupe key
jsonb_build_object('kind', 'discount'), -- placeholder; see step 4
jsonb_build_object('kind', 'discount'),
lc.created_at,
lc.created_at
FROM legacy_conversion lc
JOIN customer c_referrer ON c_referrer.external_id = lc.referrer_user_id::text
AND c_referrer.deleted_at IS NULL
JOIN customer c_referee ON c_referee.external_id = lc.referee_user_id::text
AND c_referee.deleted_at IS NULL
JOIN referral_code rc ON rc.referrer_customer_id = c_referrer.id
AND rc.program_id = '<your-programId>'::uuid
ON CONFLICT (code_id, referee_customer_id) DO NOTHING;The (code_id, referee_customer_id) unique constraint blocks duplicate inserts; the partial unique on (code_id, conversion_event_id) WHERE conversion_event_id IS NOT NULL blocks duplicate event-id replays. Together they make the importer safely re-runnable.
Step 4: decide what to do with historical vouchers
Three options, depending on how you handled rewards in the legacy system:
-
The legacy rewards were already redeemed — most common for old data. Don’t import the voucher codes at all. The placeholder
{"kind": "discount"}outcome in the conversion row is enough to audit “yes, this conversion happened.” Users won’t see anything new. -
The legacy rewards are still active and your old system honors them — keep that system running for the legacy codes. Don’t try to mirror them into OfferKit; you’d just have two sources of truth.
-
You want OfferKit to start honoring the legacy codes — for each active legacy voucher,
POST /api/v1/voucherswith the originalcode,campaignId, andtype/discount. Then update the conversion row’sreferrer_outcome.voucherCode/referee_outcome.voucherCodeto reference them. This is the most work — only do it if you’re decommissioning the old system.
Step 5: verify
After the import, sanity-check totals:
SELECT
(SELECT COUNT(*) FROM legacy_referral) AS legacy_codes,
(SELECT COUNT(*) FROM referral_code WHERE program_id = '<your-programId>') AS offerkit_codes,
(SELECT COUNT(*) FROM legacy_conversion) AS legacy_conversions,
(SELECT COUNT(*) FROM referral_conversion rc
JOIN referral_code rcd ON rcd.id = rc.code_id
WHERE rcd.program_id = '<your-programId>') AS offerkit_conversions;Spot-check a handful of users on the OfferKit dashboard at Referrals → <program> → Codes — each legacy referrer should now have one code; clicking through should show the right conversion count.
Going live
Once the import settles, point your application at the SDK (Referrals integration guide). All new conversions from this point forward go through referrals.convert, which issues fresh rewards. Legacy rows stay frozen as historical evidence — they’re never re-converted.
Keep the legacy table around for at least one billing cycle as a safety net. If something goes wrong, you can re-run Step 3 because of the ON CONFLICT guards.