OfferKitDocs

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:

  1. Referrers who hold a code — each user with a code that may still be shared. Becomes one referral_code row.
  2. Past conversions — each successful referral (referrer + referee pair) where the rewards were already issued. Becomes one referral_conversion row.

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 dataOfferKit fieldNotes
Referrer user idreferral_code.referrer_customer_idCustomer must exist in OfferKit first (see Step 1).
Referee user idreferral_conversion.referee_customer_idSame.
Code stringreferral_code.codeMust be globally unique inside the OfferKit deployment.
Original event idreferral_conversion.conversion_event_idOptional but strongly recommended — your old order id is the cleanest dedupe key.
Issued voucher code(s)referral_conversion.referrer_outcome.voucherCode / referee_outcome.voucherCodeOnly 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:

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

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

  3. You want OfferKit to start honoring the legacy codes — for each active legacy voucher, POST /api/v1/vouchers with the original code, campaignId, and type/discount. Then update the conversion row’s referrer_outcome.voucherCode / referee_outcome.voucherCode to 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.