vevee.customers.alias()POST /api/v1/customers/aliassk_live_

Make one end-user id an alias of another. After the call, every Vevee call with either id behaves as the canonical id (to): usage, limits, subscriptions, credits, prompt logs and media. Secret key only.

i
Metering, not just analytics. analytics.alias() and identify() only merge analytics people. customers.alias() merges what the customer is entitled to and has consumed.

Signature

vevee.customers.alias(args: {
  from: string;            // the id that becomes an alias (1 to 200 chars)
  to: string;              // the canonical id (1 to 200 chars, not equal to from)
  consentGiven?: boolean;  // only needed to link analytics when an id is an anon_ session
  dryRun?: boolean;        // report what would happen, write nothing
}): Promise<AliasReport>

vevee.customers.aliasMany(
  pairs: Array<{ from: string; to: string }>,
  opts?: { chunkSize?: number; dryRun?: boolean },   // chunkSize 1 to 500, default 500
): Promise<{ results: AliasBatchResult[]; billableOperations: number }>

vevee.customers.get(userId: string): Promise<{
  canonicalId: string;
  aliases: string[];
  duplicateActiveSubscriptions: DuplicateActiveSubscriptions | null;
}>

Ids are matched exactly and case-sensitively: Mario@Gmail.com is not mario@gmail.com. Aliasing is one hop deep: if to is itself an alias, the call resolves it to its canonical id first.

Response: AliasReport

interface AliasReport {
  mergeId: string | null;       // null on dryRun
  from: string; to: string; mode: 'live' | 'test';
  dryRun: boolean;
  alreadyMerged: boolean;       // a repeat or racing call: returns the stored report
  historyPending: boolean;      // history rows still moving in the background
  billableOperations: number;   // rows this merge writes that count as metering operations
  moved: {
    events: number; eventLogs: number; counters: number; reservations: number;
    subscriptions: number; subscriptionEvents: number; creditBalances: number;
    creditLedger: number; mediaAssets: number; composeGenerations: number;
    aliasesRepointed: number;
  };
  conflicts: {
    counters: {
      summed: number;
      carried: Array<{ fromGroupId: string; toGroupId: string }>;
      unpaired: string[];
    };
    subscriptions: DuplicateActiveSubscriptions | null;
    // Default (unpaid) subscriptions ended instead of kept as duplicates.
    endedSubscriptions: SubRef[];
    credits: Array<{ externalRef: string; survivorId: string; droppedId: string;
                     remaining: number; overage: number }>;
  };
  analytics: 'linked' | 'already_linked' | 'skipped_consent_required';
}

Large history (events, logs, media) moves in the background in chunks of 500 rows, so moved history counts can be 0 with historyPending: true right after the call. Entitlement and counters move immediately, in the call itself.

Example: guest registration

A guest uses your app under a RevenueCat anonymous id, then signs up. Alias the anonymous id to your account id right after the login:

// client: Purchases.logIn(supabaseUuid) has just completed
// server, with the secret key:
const report = await vevee.customers.alias({
  from: rcAnonymousId,
  to: supabaseUuid,
});

// report.alreadyMerged is true if the call was repeated (double tap, retry)

Calling it twice for the same pair is safe: the second call returns alreadyMerged: true and costs nothing.

!
Default plans are cleaned up for you. If the guest and the account both have a default subscription with no provider and no externalId(for example the Free plan you give everyone), the merge keeps one and ends the other, so registering never shows a “2 subscriptions” notice. On every subscription write, always pass provider and externalId: with one active subscription a new externalId replaces the stored one on that row, while with two or more active subscriptions it creates a new subscription. A webhook upsert on the alias id that races the merge can create a duplicate, which the background sweep then reports in duplicateActiveSubscriptions.

Example: email to UUID migration

Move a whole user base off email-as-id. Run a dry run first to see conflicts and the cost, then run it for real:

const pairs = users.map((u) => ({ from: u.email, to: u.uuid }));

const preview = await vevee.customers.aliasMany(pairs, { dryRun: true });
console.log('would cost', preview.billableOperations, 'operations');
console.log(preview.results.filter((r) => !r.ok));   // per-pair errors

const done = await vevee.customers.aliasMany(pairs);
console.log('billed', done.billableOperations);

aliasMany sends up to 500 pairs per request, one request at a time. Each pair is merged independently, so one failing pair does not stop the rest. A failed pair has ok: false and an error.code of alias_conflict, alias_cycle, workspace_limit_reached, test_quota_exceeded, internal_error or not_processed (the request ran out of time before reaching that pair: resubmit it). Every result carries its own billableOperations, and the response carries the total.

Conflict rules

Counters: summed, never a fresh quota

Usage of both ids is added together per limit group and period, so merging can never hand a user a new quota. If the two sides are on different plans, the usage of the plan that loses is carried onto the surviving plan’s matching groups (same group, or same stable key). Groups with no match stay as history under the canonical id and do not count against the current plan; they are listed in conflicts.counters.unpaired.

Subscriptions: one primary, the rest kept

If both ids had a paid subscription (one with a provider or externalId), both rows are kept. The primary is the best active one, ranked by plan rank (set it on each plan), then most recently started. Entitlement and limits come from the primary. The other paid rows are reported in conflicts.subscriptions as duplicates. Default rows without provider ids are ended instead of kept and listed in conflicts.endedSubscriptions: when a paid row exists, every default row on a plan ranked at or below it ends; when none is paid, the best default row stays and the others on the same or a lower-ranked plan end. Usage from an ended row is carried onto the primary, so nobody gets a fresh quota. Rows written by SDK 1.8.0 have no provider ids even when paid, so a default row on a higher-ranked plan than every paid row is never ended. If the primary later ends while a duplicate is still active, the duplicate is promoted automatically and the user is never blocked. See managing subscriptions.

Credits: duplicate grants are deduplicated

All credit balances move to the canonical id. If both sides received the same purchase (same pack, item and externalRef), one balance survives with the combined usage subtracted from it (floored at 0), and the other is closed. Any overage is recorded in conflicts.credits, never clawed back. If either copy was refunded, the survivor is revoked too.

What a merge costs

A metering operation is one row written to events, subscription_events or the credit ledger. A merge writes a few:

RowTableWhen
mergedsubscription eventsOnce per merge in which the alias side had a subscription
promotedsubscription eventsEach later promotion of a duplicate (billed when it happens, not part of a merge report)
merged_from_aliascredit ledgerOne per moved credit balance
merge_dedupecredit ledgerTwo per deduplicated grant
billableOperations = merged + merged_from_alias + 2 * dedupes
  • Moving existing history is free: re-pointing 10,000 events costs 0.
  • A user with no subscription and no credits costs 0 to merge, which is the typical email to UUID case.
  • A repeat call (alreadyMerged: true) costs 0.
  • dryRun: true writes nothing, costs nothing and is never blocked. It returns the same billableOperations the real merge would.
  • Merges respect the workspace operation cap like any other write. Over the cap, the call fails with workspace_limit_reached; a batch stops part-way and reports that error for the remaining pairs.

Errors

  • alias_conflict (409): from is already an alias of a different canonical id, or the pair is otherwise contradictory.
  • alias_cycle (409): from and to are the same id, or the alias would loop back on itself (for example to is already an alias of from).
  • workspace_limit_reached (429): the workspace is over its operation cap. Dry runs are not blocked.
  • test_quota_exceeded: a test key was used and the sandbox quota is spent.
  • invalid_request (400): malformed body or an empty or over-long id.
  • requires_secret_key (403): a public key (pk_*) was used. The batch endpoint behaves the same.
  • invalid_key (401): missing, malformed or revoked key.

Reading duplicate subscriptions

After a merge a user can have two active subscriptions (for example Apple and Stripe). Read them server-side with the secret key and tell the user which one to cancel. The field duplicateActiveSubscriptions appears on customers.get(), GET /v1/usage and GET /v1/users/:userId:

const customer = await vevee.customers.get(userId);
const dup = customer.duplicateActiveSubscriptions;
if (dup) {
  // dup.primary: the subscription that grants entitlement
  // dup.duplicates: the others, each with provider, externalId, planName, startedAt, endsAt
  notifyUserToCancel(dup.duplicates);
}
!
Never available with a public key. A pk_* key gets 404 not_found from GET /v1/customers/:id (so existence does not leak), and duplicateActiveSubscriptions is never included in public responses such as /v1/usage/me.