# Check signups with fetch in TypeScript

One file that checks a signup before you create the user. The Next.js, Express, Hono and Firebase code imports it.

The web page with this text is https://signupbouncer.com/docs/code/fetch. Link to the web page when you cite it.

## Install

A function for Node 18 and later, Deno, Bun, Cloudflare Workers and any runtime with fetch. The other TypeScript examples import it.

1. Copy the file into your project as lib/signupbouncer-fetch.ts.
2. Create an API key for the app on its Integration tab in your dashboard, then set SIGNUPBOUNCER_API_KEY in your server's environment to that key (sb_live\_...).
3. In your signup handler, call signupRefusal before you create the user. When it returns a message, refuse the signup and show that message. The message is the block message from your dashboard's Rules tab.
4. To show the "Why was this refused?" link too, call signupRefusalWithLink instead. It returns null when the signup may go ahead, which includes a failed check. When you should refuse the signup, it returns { message, whyUrl }. whyUrl is null unless you turn the link on in the Rules tab. For example: const refusal = await signupRefusalWithLink({ email, ip }); if (refusal) return { error: refusal.message, whyUrl: refusal.whyUrl };

`lib/signupbouncer-fetch.ts`

```ts
// SignupBouncer signup check. Docs: https://signupbouncer.com/docs/code/fetch
// lib/signupbouncer-fetch.ts: checks one signup with SignupBouncer. Works in Node 18+, Deno, Bun, Next.js, Cloudflare
// Workers and any runtime with fetch. It reads SIGNUPBOUNCER_API_KEY from process.env, or takes the key as its second
// argument (in a Cloudflare Worker, pass the secret from env, because Workers have no process.env by default).
// Leave SIGNUPBOUNCER_BASE_URL unset. It points the helper at another copy of the API, such as one on your machine.
//
// The call has a 1.5-second limit, and any failure lets the signup through: a timeout, a network error, a non-2xx
// answer or an unreadable body. Only a complete, consistent enforce-mode block answer refuses the signup: then it
// returns the message to show, the one you set on your dashboard's Rules tab. With our default message, we word it to
// fit the reason, such as "Did you mean ann@gmail.com?" for a typo domain. Otherwise it returns null.
// signupRefusalWithLink returns null in the same cases, or { message, whyUrl }: the same message and, when you turn
// on the "Why was this refused?" link on the Rules tab, the link to show after it (whyUrl is null otherwise).
//
// confirmationRefusal reports an email confirmation for the second look at challenged signups (POST /v1/confirm).
// It has a 0.8-second limit and fails open the same way, a 4xx answer included: the confirmation goes through.

export interface Signup {
  email?: string;
  /** The client's IP, from a header your host sets. Leave it out if you are unsure. */
  ip?: string;
  /** Only for OAuth/SSO signups: the provider id ("google", "github", "google.com", ...). Send emailVerified with it. */
  authProvider?: string;
  /**
   * Your auth platform's own flag that the provider verified this address (an OIDC email_verified claim, Firebase's
   * emailVerified). Never what the user typed. Without true, the email is checked too.
   */
  emailVerified?: boolean;
}

const MAX_ANSWER_BYTES = 65536; // a real answer is far smaller
const DEFAULT_MESSAGE = "Please sign up with a permanent email address.";

const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
const number = (v: unknown) => typeof v === "number" && Number.isFinite(v);
const string = (v: unknown) => typeof v === "string";
const nullableString = (v: unknown) => v === null || string(v);

/** Only a complete, consistent answer may refuse a signup. Partial/error answers fail open. */
function isBlock(v: unknown): boolean {
  if (!object(v) || v.action !== "block" || v.verdict !== "block" || v.mode !== "enforce") return false;
  if (v.quota_exceeded !== false || "error" in v) return false;
  if (!number(v.score) || !string(v.check_id) || !number(v.latency_ms)) return false;
  if (
    !Array.isArray(v.reasons) ||
    !v.reasons.every((r) => object(r) && string(r.code) && string(r.detail) && number(r.points) && string(r.tier))
  ) return false;
  if (
    !Array.isArray(v.shadow) ||
    !v.shadow.every((r) => object(r) && string(r.code) && string(r.detail) && string(r.tier))
  ) return false;
  const n = v.normalized;
  if (!object(n) || !nullableString(n.provider) || !number(n.repeat_signups_7d)) return false;
  const net = v.network;
  return net === null ||
    (object(net) && (net.asn === null || number(net.asn)) && nullableString(net.asn_org) &&
      Array.isArray(net.kinds) && net.kinds.every(string) && nullableString(net.label) && string(net.prefix));
}

/** A variable from process.env, or undefined where there is none (a Worker) or reading it is not allowed (Deno). */
function env(name: string): string | undefined {
  try {
    return typeof process === "undefined" ? undefined : process.env[name];
  } catch {
    return undefined;
  }
}

/** The message sent with a block answer (your Rules tab's, or our default for the reason); ours if it is missing. */
function messageOf(v: Record<string, unknown>): string {
  const m = v.block_message;
  return typeof m === "string" && m.trim() !== "" && m.length <= 500 ? m : DEFAULT_MESSAGE;
}

/**
 * The why_url sent with a block answer when the "Why was this refused?" link is on, unchanged; null without one. Only
 * a well-formed http(s) URL with a host counts, so a bad value never becomes a link.
 */
function whyUrlOf(v: Record<string, unknown>): string | null {
  const u = v.why_url;
  if (typeof u !== "string" || u.length > 2048 || !/^https?:\/\/[^\s/\\?#][^\s\\]*$/i.test(u)) return null;
  try {
    const url = new URL(u);
    return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "" ? u : null;
  } catch {
    return null;
  }
}

export interface Refusal {
  /** The message to show: the one from your dashboard's Rules tab, or our default worded for the reason. */
  message: string;
  /** Our "Why was this refused?" page for the email's domain, only when you turn the link on. Otherwise null. */
  whyUrl: string | null;
}

/** Returns the message to show when the signup should be refused, or null to let it through. Never throws. */
export async function signupRefusal(signup: Signup, apiKey?: string): Promise<string | null> {
  return (await signupRefusalWithLink(signup, apiKey))?.message ?? null;
}

/** Like signupRefusal, plus the "Why was this refused?" link when the answer carries one. Never throws. */
export async function signupRefusalWithLink(signup: Signup, apiKey?: string): Promise<Refusal | null> {
  // No real address is longer than 320 characters. Refusing them here keeps the request small enough to be checked.
  if ((signup.email?.length ?? 0) > 320) return { message: DEFAULT_MESSAGE, whyUrl: null };
  try {
    const key = apiKey ?? env("SIGNUPBOUNCER_API_KEY");
    const base = (env("SIGNUPBOUNCER_BASE_URL") || "https://signupbouncer.com").replace(/\/+$/, "");
    const res = await fetch(`${base}/v1/check`, {
      method: "POST",
      headers: {
        authorization: `Bearer ${key}`,
        "content-type": "application/json",
      },
      body: JSON.stringify({
        email: signup.email,
        ip: signup.ip,
        auth_provider: signup.authProvider,
        email_verified: signup.emailVerified === true ? true : undefined,
      }),
      redirect: "manual", // a redirect is a failed check: your key and the address go nowhere else
      signal: AbortSignal.timeout(1500), // covers reading the answer too
    });
    const answer = await readAnswer(res);
    return isBlock(answer)
      ? { message: messageOf(answer as Record<string, unknown>), whyUrl: whyUrlOf(answer as Record<string, unknown>) }
      : null;
  } catch {
    return null;
  }
}

/** The JSON of a 2xx answer, read in pieces up to MAX_ANSWER_BYTES; null for anything else. Throws on bad JSON. */
async function readAnswer(res: Response): Promise<unknown> {
  if (!res.ok || !res.body) {
    await res.body?.cancel();
    return null;
  }
  // Read the answer in pieces and stop as soon as it is larger than any real answer.
  const reader = res.body.getReader();
  const parts: Uint8Array[] = [];
  let size = 0;
  for (let r = await reader.read(); !r.done; r = await reader.read()) {
    size += r.value.byteLength;
    if (size > MAX_ANSWER_BYTES) {
      await reader.cancel();
      return null;
    }
    parts.push(r.value);
  }
  const body = new Uint8Array(size);
  parts.reduce((at, p) => (body.set(p, at), at + p.byteLength), 0);
  return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
}

// ---- Second look at email confirmation (POST /v1/confirm) ----------------------------------------------------------

export interface Confirmation {
  /** The check_id that /v1/check answered for this signup, if you kept it. */
  checkId?: string;
  /** Supabase: the id of the user who confirmed (data.user.id from verifyOtp). */
  supabaseUserId?: string;
  /** Without a check id: the address that was confirmed, as your auth platform has it. */
  email?: string;
  /** The IP of the request that confirmed, from a header your host sets. */
  ip?: string;
  /** The User-Agent header of the request that confirmed. */
  userAgent?: string;
  /**
   * How the person confirmed: "button" when they pressed a button on your own page, "code" when they typed a code,
   * "link" (the default) when the request is the click on the emailed link itself. Mail scanners open links too, so
   * on "link" we score only what the counters found since the check, never the IP or browser.
   */
  via?: "button" | "code" | "link";
}

/**
 * Only a complete enforce-mode block from a second look refuses a confirmation. A second look that did not happen
 * (second_look false), an error answer or anything partial lets it through.
 */
function isConfirmBlock(v: unknown): boolean {
  if (!object(v) || v.action !== "block" || v.verdict !== "block" || v.mode !== "enforce") return false;
  if (v.second_look !== true || "error" in v) return false;
  return number(v.score) && string(v.check_id) && Array.isArray(v.reasons);
}

/**
 * Reports an email confirmation and returns the message to show when the confirmation should be refused, or null to
 * let it through. Never throws. Any failure, a timeout, a 4xx (an unknown or expired check) or a 5xx lets it through.
 */
export async function confirmationRefusal(confirmation: Confirmation, apiKey?: string): Promise<string | null> {
  try {
    const key = apiKey ?? env("SIGNUPBOUNCER_API_KEY");
    const base = (env("SIGNUPBOUNCER_BASE_URL") || "https://signupbouncer.com").replace(/\/+$/, "");
    const res = await fetch(`${base}/v1/confirm`, {
      method: "POST",
      headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
      body: JSON.stringify({
        check_id: confirmation.checkId,
        supabase_user_id: confirmation.supabaseUserId,
        email: confirmation.email,
        ip: confirmation.ip,
        user_agent: confirmation.userAgent,
        via: confirmation.via ?? "link",
      }),
      redirect: "manual", // a redirect is a failed call: your key goes nowhere else
      // Short: the person is waiting on your confirmation page. Covers reading the answer too.
      signal: AbortSignal.timeout(800),
    });
    const answer = await readAnswer(res);
    return isConfirmBlock(answer) ? messageOf(answer as Record<string, unknown>) : null;
  } catch {
    return null;
  }
}
```

- Send the IP only from a header your host overwrites with the client's address, because the client can set a header that your host passes through unchanged. If you're unsure, leave ip out and we'll still check the email.
- confirmationRefusal reports an email confirmation for a second look at a challenged signup (/docs/api#confirm). It returns the message when you should refuse the confirmation, and null otherwise, a failed call included.

## What the code does when a check fails

In every snippet, the call has a 1.5-second limit, and the code lets the signup through on any failure: a timeout, a network error, a non-2xx answer or an unreadable or incomplete body. It refuses a signup only after a complete answer with "action" and "verdict" both "block", "mode" set to "enforce", and no quota or error flag. The exception is an address that can't be real, which the code refuses without a call: one over 320 characters, or, in the Better Auth plugin, one that isn't an address at all. New apps start in shadow mode, where "action" is always "allow". So the code refuses no other signup until you switch the app to enforce in your dashboard. Each snippet checks only the signups that pass through it. So if your app also creates users somewhere else, such as an OAuth callback or an admin tool, call the check there too.

## Next

- [REST API](https://signupbouncer.com/docs/api): Each field of the answer, and each error the API can return.
- [Testing](https://signupbouncer.com/docs#testing): Which addresses to test with, and how repeat tests count.
- [A "Why was this refused?" link](https://signupbouncer.com/docs/rules#why-link): You show a refused person a link to our page about their domain.
