Mapping Stripe Customers to Tenant IDs Safely

The identifier mapping between your tenants and Stripe's customers is where billing bugs become customer-visible, and this page makes it one-to-one, idempotent and verifiable. It is a focused part of Billing Sync with Stripe: creating the link exactly once, resolving it correctly on every webhook, and detecting drift before it reaches an invoice.

Problem Framing

The mapping looks trivial: one tenant, one customer, store the identifier. Three realities break it. Retries โ€” a customer creation that timed out but succeeded leaves an orphan customer, and the retry creates a second one, so the tenant now has two billing records and receives two invoices. Email is not a key โ€” the same address appears on several tenants (a consultant with three clients) and one tenant's address changes over time, so any resolution path that matches on email eventually matches the wrong tenant. Direction โ€” resolving tenant from customer and customer from tenant are different lookups with different failure modes, and both are needed.

The mapping is also a security boundary, because everything downstream is scoped by it. A webhook that resolves to the wrong tenant applies a subscription change to the wrong customer: someone gets an upgrade they did not pay for, or a downgrade they did.

The design is a local unique index in both directions, an idempotency key derived from the tenant identifier, and Stripe metadata carrying the tenant identifier as a cross-check โ€” with the local table always authoritative.

Step-by-Step Guide

1. Enforce one-to-one locally, before Stripe is involved

ALTER TABLE tenant
  ADD COLUMN stripe_customer_id text,
  ADD CONSTRAINT tenant_stripe_customer_unique UNIQUE (stripe_customer_id);

-- Reverse lookup for webhooks, without scanning the tenant table.
CREATE TABLE stripe_customer_map (
  stripe_customer_id text PRIMARY KEY,
  tenant_id          uuid NOT NULL UNIQUE,
  linked_at          timestamptz NOT NULL DEFAULT now()
);

Two unique constraints, one per direction. They make the invariant a database property rather than an application convention, so a race that produces a second customer fails on insert instead of silently succeeding.

2. Create the customer idempotently, keyed by the tenant

// provision-customer.ts
export async function ensureCustomer(tenantId: string, email: string, name: string) {
  const existing = await db.oneOrNone(
    `SELECT stripe_customer_id FROM tenant WHERE id = $1`, [tenantId]);
  if (existing?.stripe_customer_id) return existing.stripe_customer_id;

  const customer = await stripe.customers.create(
    { email, name, metadata: { tenant_id: tenantId } },
    { idempotencyKey: `customer:create:v1:${tenantId}` },   // same key โ‡’ same customer, always
  );

  await db.tx(async (t) => {
    await t.query(`UPDATE tenant SET stripe_customer_id = $1 WHERE id = $2 AND stripe_customer_id IS NULL`,
      [customer.id, tenantId]);
    await t.query(`INSERT INTO stripe_customer_map (stripe_customer_id, tenant_id) VALUES ($1,$2)
                   ON CONFLICT (tenant_id) DO NOTHING`, [customer.id, tenantId]);
  });
  return customer.id;
}

The idempotency key is derived from the tenant identifier and versioned, so a retry after a timeout returns the customer that was already created rather than making a second one. Versioning the key (v1) leaves room to deliberately re-key later without colliding with historical requests.

3. Resolve webhooks through the local map, never through email

// resolve.ts
export async function tenantForEvent(event: Stripe.Event): Promise<string> {
  const obj = event.data.object as { customer?: string | { id: string }; metadata?: Record<string, string> };
  const customerId = typeof obj.customer === 'string' ? obj.customer : obj.customer?.id;

  if (customerId) {
    const row = await db.oneOrNone(
      `SELECT tenant_id FROM stripe_customer_map WHERE stripe_customer_id = $1`, [customerId]);
    if (row) return row.tenant_id;
  }

  // Metadata is a fallback and a cross-check, never the primary source.
  const fromMeta = obj.metadata?.tenant_id;
  if (fromMeta && await tenantExists(fromMeta)) {
    await alert('billing.map_missing', { customerId, tenantId: fromMeta });
    return fromMeta;
  }
  throw new UnmappedCustomerError(customerId ?? 'unknown');
}

Throwing on an unmapped customer is correct. The alternative โ€” guessing from the email on the event โ€” is how a subscription change lands on a different tenant that happens to share an address.

4. Never mutate the mapping in place

-- Re-linking is an explicit, audited operation, not an UPDATE somebody runs by hand.
CREATE TABLE stripe_map_change (
  id           bigserial PRIMARY KEY,
  tenant_id    uuid NOT NULL,
  old_customer text,
  new_customer text NOT NULL,
  reason       text NOT NULL,
  approved_by  text NOT NULL,
  changed_at   timestamptz NOT NULL DEFAULT now()
);
// relink.ts โ€” used for genuine cases: a merged account, a migrated Stripe account
export async function relink(tenantId: string, newCustomerId: string, reason: string, approver: string) {
  await db.tx(async (t) => {
    const old = await t.oneOrNone(`SELECT stripe_customer_id FROM tenant WHERE id = $1`, [tenantId]);
    await t.query(`DELETE FROM stripe_customer_map WHERE tenant_id = $1`, [tenantId]);
    await t.query(`INSERT INTO stripe_customer_map (stripe_customer_id, tenant_id) VALUES ($1,$2)`,
      [newCustomerId, tenantId]);
    await t.query(`UPDATE tenant SET stripe_customer_id = $1 WHERE id = $2`, [newCustomerId, tenantId]);
    await t.query(`INSERT INTO stripe_map_change (tenant_id, old_customer, new_customer, reason, approved_by)
                   VALUES ($1,$2,$3,$4,$5)`,
      [tenantId, old?.stripe_customer_id ?? null, newCustomerId, reason, approver]);
  });
}

5. Reconcile the mapping in both directions, nightly

// reconcile-map.ts
export async function reconcileMapping() {
  const problems: Problem[] = [];

  for await (const customer of stripe.customers.list({ limit: 100 })) {
    const tenantId = customer.metadata?.tenant_id;
    const local = await db.oneOrNone(
      `SELECT tenant_id FROM stripe_customer_map WHERE stripe_customer_id = $1`, [customer.id]);

    if (!local && !customer.deleted) problems.push({ kind: 'stripe_only', customerId: customer.id, tenantId });
    else if (local && tenantId && local.tenant_id !== tenantId)
      problems.push({ kind: 'metadata_mismatch', customerId: customer.id,
                      localTenant: local.tenant_id, metadataTenant: tenantId });
  }

  const orphans = await db.query(
    `SELECT id FROM tenant WHERE stripe_customer_id IS NULL AND plan_key <> 'free'`);
  for (const row of orphans.rows) problems.push({ kind: 'paid_without_customer', tenantId: row.id });

  return problems;
}

Verification

// mapping.spec.ts
it('a retried creation returns the same customer', async () => {
  const a = await ensureCustomer('acme', 'billing@acme.test', 'Acme');
  const b = await ensureCustomer('acme', 'billing@acme.test', 'Acme');
  expect(a).toBe(b);
  expect(await countStripeCustomersFor('acme')).toBe(1);
});

it('two tenants sharing an email get separate customers', async () => {
  const a = await ensureCustomer('acme',   'consultant@example.test', 'Acme');
  const b = await ensureCustomer('globex', 'consultant@example.test', 'Globex');
  expect(a).not.toBe(b);
});

it('rejects an event for an unmapped customer instead of guessing', async () => {
  await expect(tenantForEvent(invoicePaid({ customer: 'cus_unknown' })))
    .rejects.toBeInstanceOf(UnmappedCustomerError);
});

it('refuses to link one customer to two tenants', async () => {
  await link('acme', 'cus_123');
  await expect(link('globex', 'cus_123')).rejects.toThrow(/unique/);
});
-- Standing checks; all three should return nothing.
SELECT stripe_customer_id, count(*) FROM stripe_customer_map
GROUP BY 1 HAVING count(*) > 1;

SELECT id FROM tenant WHERE plan_key <> 'free' AND stripe_customer_id IS NULL;

SELECT t.id FROM tenant t
LEFT JOIN stripe_customer_map m ON m.tenant_id = t.id
WHERE t.stripe_customer_id IS NOT NULL AND m.stripe_customer_id IS DISTINCT FROM t.stripe_customer_id;

The third query catches the two local records disagreeing with each other, which happens whenever someone updates the tenant row without the map โ€” and it is the one that produces the strangest downstream symptoms.

Failure Modes & Gotchas

FAQ

Should the tenant identifier be stored in Stripe metadata? Yes, as a cross-check and as a human aid when someone is looking at the dashboard during an incident โ€” but never as the source of truth. Metadata is editable by anyone with dashboard access and is not enforced for uniqueness, so treating it as authoritative means a stray edit can redirect a customer's billing. Local map first, metadata as corroboration.

What about tenants that should share one paying account? That is a legitimate model โ€” a parent organisation paying for several workspaces โ€” and it needs an explicit billing_account entity between tenant and customer rather than a many-to-one hack on the existing mapping. One customer per billing account, one subscription item per tenant, and the tenant-to-account link stored locally with the same uniqueness discipline. Bending the tenant-to-customer mapping to be many-to-one breaks every reconciliation query built on it.

How should test-mode and live-mode customers be kept apart? Store the mode alongside the identifier and make the uniqueness constraint cover both, because cus_ identifiers from the two modes look identical and will eventually be pasted into the wrong place. Better still, use separate databases or a mode column that every billing query filters on โ€” a test-mode customer resolving in production is a small bug with a very loud symptom.

Should the mapping be exposed in the customer-facing interface? The provider's customer identifier is reasonable to show to a tenant administrator on a billing settings page, because it is the reference they will quote when contacting support and it is visible to them in the provider's own portal anyway. What should not be editable is the mapping itself โ€” a text field that lets an administrator change which provider customer their tenant points at is a way to attach someone else's payment history to their own account.