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.
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.
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:
| Row | Table | When |
|---|---|---|
merged | subscription events | Once per merge in which the alias side had a subscription |
promoted | subscription events | Each later promotion of a duplicate (billed when it happens, not part of a merge report) |
merged_from_alias | credit ledger | One per moved credit balance |
merge_dedupe | credit ledger | Two 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: truewrites nothing, costs nothing and is never blocked. It returns the samebillableOperationsthe 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):fromis already an alias of a different canonical id, or the pair is otherwise contradictory.alias_cycle(409):fromandtoare the same id, or the alias would loop back on itself (for exampletois already an alias offrom).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);
}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.