Rebounder Tech Blog

Written by the people who actually run these systems in production.

An Empty contractId Gives invalid input syntax uuid

Published About 6 min readBy the Rebounder engineering team — the people who operate these systems

This article may contain affiliate links. Its content is not affected by advertising.

In short

Keep the caller's type a required contractId: string and convert null to an empty string, and Postgres rejects it with invalid input syntax for type uuid — driving a webhook redelivery loop.

The short version

Company-level card registration with no contract attached (Stripe’s Setup Mode) reaches persistSavedCard on a path where no contractId is passed. But the parameter type was still a required contractId: string, so the caller converted null to an empty string with ?? "". An empty string is not a valid value for the uuid column contracts.id, and Postgres rejects the UPDATE with invalid input syntax for type uuid. That exception was caught by the webhook route, which by design deletes the idempotency-ledger row and returns 500, so Stripe redelivered the same event and the same input produced the same exception — a structural infinite loop.

What it looks like

The Stripe payment platform has two kinds of card registration: the normal kind tied to a contract (contracts), and “company-level” registration that attaches a card to a company (companies) with no contract involved. The latter uses Stripe’s Setup Mode (registering a card with no charge).

The handler for the setup_intent.succeeded event was written like this.

case "setup_intent.succeeded": {
  const si = event.data.object as Stripe.SetupIntent;
  const contractId = si.metadata?.contract_id ?? null;
  const companyId = si.metadata?.company_id ?? null;
  const customerId = typeof si.customer === "string" ? si.customer : (si.customer?.id ?? null);
  const card = extractCard(si.payment_method as Stripe.PaymentMethod | string | null);
  if (contractId || companyId) {
    await persistSavedCard(admin, {
      contractId: contractId ?? "",
      billingCompanyId: companyId,
      customerId,
      card,
    });
  }
  ...
}

Company-level registration has no metadata.contract_id, so contractId is null. But to call persistSavedCard it is passed as contractId ?? "" — an empty string. Run that path and persistSavedCard UPDATEs contracts with an empty id, and Postgres returns invalid input syntax for type uuid.

Why

The cause was a mismatch: the real data allows null while the function’s type signature alone still required a string.

export async function persistSavedCard(
  admin: SupabaseClient,
  args: {
    contractId: string;                 // ← null was never anticipated
    billingCompanyId?: string | null;
    customerId?: string | null;
    card: CardInfo;
  }
): Promise<void> {
  const { contractId, billingCompanyId, customerId, card } = args;
  ...
  const cPatch: Record<string, unknown> = {};
  if (card.paymentMethodId) cPatch.stripe_payment_method_id = card.paymentMethodId;
  if (card.last4) cPatch.card_last4 = card.last4;
  if (card.brand) cPatch.card_brand = card.brand;
  if (Object.keys(cPatch).length > 0) {
    await admin.from("contracts").update(cPatch).eq("id", contractId);
  }
}

persistSavedCard updates contracts unconditionally, on the premise that a contractId exists. When the “no contract” case of company-level registration was added later, the function’s type was left alone and only the caller was made to fit with contractId ?? "". TypeScript’s type check passes (a string is being passed to a string). At runtime the empty string reaches the uuid column and only surfaces as a constraint violation on the Postgres side. Agreeing at the type level and being a meaningful value at runtime are different things, and silencing a type error with ?? hides exactly this mismatch.

The exception is not swallowed either; it propagates upward. On an exception, the webhook route was designed to delete the row from the idempotency ledger (stripe_events) and return 500.

try {
  const { contractId } = await handleStripeEvent(admin, stripe, event);
  await admin
    .from("stripe_events")
    .update({ processed_at: new Date().toISOString(), contract_id: contractId })
    .eq("id", event.id);
  return Response.json({ received: true });
} catch (e) {
  // Delete the ledger row and 500 → let Stripe's redelivery reprocess it (at-least-once).
  await admin.from("stripe_events").delete().eq("id", event.id);
  const msg = e instanceof Error ? e.message : "error";
  await notifySlack(/* ... */);
  return new Response(`error: ${msg}`, { status: 500 });
}

Deleting the ledger row and returning 500 exists so that “if the work merely failed on a transient DB fault, Stripe’s redelivery can retry it”. But this exception was not transient — it is a permanent failure that recurs for certain as long as the same event passes the same empty string. Stripe reads 500 as unprocessed and redelivers; each redelivery hands the same setup_intent.succeeded event with the same empty string to the handler, which raises the same exception and returns 500 again. Every single company-level card registration would turn this path into an endless redelivery loop.

Fixing it

Two changes: make the type nullable, and guard inside the function.

export async function persistSavedCard(
  admin: SupabaseClient,
  args: {
    contractId?: string | null;   // changed to optional
    billingCompanyId?: string | null;
    customerId?: string | null;
    card: CardInfo;
  }
): Promise<void> {
  const { contractId, billingCompanyId, customerId, card } = args;
  ...
  // With no contractId (company-level card registration = Setup Mode), skip the contract update.
  // Throwing an empty string at a uuid column gives Postgres a syntax error → a webhook 500
  // redelivery loop. This guard is required.
  if (contractId) {
    const cPatch: Record<string, unknown> = {};
    if (card.paymentMethodId) cPatch.stripe_payment_method_id = card.paymentMethodId;
    if (card.last4) cPatch.card_last4 = card.last4;
    if (card.brand) cPatch.card_brand = card.brand;
    if (Object.keys(cPatch).length > 0) {
      await admin.from("contracts").update(cPatch).eq("id", contractId);
    }
  }
}

The caller stopped papering over it and passes null through as-is.

await persistSavedCard(admin, {
  contractId, // null makes persistSavedCard skip the contract update (company-level registration)
  billingCompanyId: companyId,
  customerId,
  card,
});

Rather than silencing the type error with ?? "", the “there is no contract” state passes through as null and the function guards it explicitly as a business rule: no contract, no contract update. Not manufacturing a value at the call site keeps the type and the meaning of the real data aligned.

Preventing a repeat

This repository runs an independent review by a separate agent immediately after an implementation commit. This defect was found 14 minutes after the commit implementing the Stripe card platform, as a finding from that review (M2: guard the empty uuid on setup_intent), and fixed the same day.

The Stripe card path itself also sits under a kill-switch, the is_enabled column of the integrations table, and the feature stayed off throughout this implementation period.

/**
 * The `integrations(provider='stripe').is_enabled` gate (kill-switch for the card path,
 * staged enablement). While off, even a card-designated contract falls back to the existing
 * invoice rail (the card path is never shown).
 */
export async function isStripeEnabled(): Promise<boolean> {
  ...
}

So no real customer’s card registration ever went down this path. The type mismatch itself would have survived to production without the review, but the two-stage arrangement — a per-feature kill-switch and an independent review right after implementation — stopped a code defect from becoming customer impact.

Frequently asked questions

Q1When does invalid input syntax for type uuid happen?

It is the error Postgres gives when a string that is not in UUID form is passed to a UUID column. An empty string is not UUID form either, so it triggers this error — and TypeScript's type checking cannot detect it.

Q2Why was contractId passed as an empty string rather than null?

Because the called function's parameter type was still a required contractId: string. To let company-level card registration (Setup Mode), which has no contract, through, null was converted with ?? "" to dodge the type error.

Q3Did this affect a real customer's card registration?

It did not. The whole Stripe card path sits behind a DB kill-switch, integrations.is_enabled, and was off. An independent agent review 14 minutes after the feature was implemented flagged it, and it was fixed before going live.

Environment verified

  • Next.js 16.2.7 / @supabase/supabase-js ^2.106.2 / stripe ^22.3.1
  • Implemented 2026-07-12, found and fixed the same day in independent review (no customer impact, is_enabled was off)

What this article is based on

  • TypeScript file lines 85-98commit 1aec8fd
  • TypeScript file lines 54-88commit 1aec8fd
  • TypeScript file lines 59-74commit 1aec8fd
  • TypeScript file lines 81-91commit a6b7ab3
  • TypeScript file lines 30-48commit 1aec8fd

Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.