REST API
The OpenAPI 3.1 description is at /openapi.json.
POST https://signupbouncer.com/v1/check
Authorization: Bearer sb_live_...
Content-Type: application/json
{ "email": "someone@mailinator.com", "ip": "203.0.113.7", "user_agent": "..." }On macOS or Linux, set SIGNUPBOUNCER_API_KEY to an API key (sign up free first if you have none) and run this:
printf 'header = "Authorization: Bearer %s"\n' \
"$SIGNUPBOUNCER_API_KEY" |
curl -K - https://signupbouncer.com/v1/check \
-H "Content-Type: application/json" \
-d '{ "email": "new_user@gmail.com", "ip": "203.0.113.7" }'We allow the signup with no reasons, and the call counts toward your monthly checks. If SIGNUPBOUNCER_API_KEY is empty, we answer 401 invalid_api_key.
Run this call again within seven days with a second app's key, and we add a repeat signup reason (repeat_inbox). We still allow the signup at the default cutoffs. For no reasons, give each app its own address, such as new_user2@gmail.com.
All fields are optional, but send at least an email or an IP. The key sets the app, so leave out app. If you send app, it must be the key's own app id, or we refuse the call with 403 app_mismatch.
With an older key, labeled "all apps (older key)" on the dashboard, we take the app from app, and use your first app without it.
For OAuth and SSO signups, send the email with auth_provider set to the provider id ("google", "github", "google.com", "microsoft-entra-id", "sso:<id>"...). Set email_verified from your auth platform's own flag (an OIDC email_verified claim, Firebase's emailVerified), never from what the user typed.
When we know the id as an OAuth or SSO provider and email_verified is true, we score only the IP and signup bursts. The domain still counts in your stats. When email_verified is missing or false, we check the email like an email signup. We do the same when we don't know the id, such as for an email or password provider ("nodemailer", "credentials").
Send the provider id and the flag only when your auth platform says an OAuth provider verified the address:
{ "email": "verified-address@example.com", "ip": "203.0.113.7", "user_agent": "...",
"auth_provider": "google", "email_verified": true }For IP-only checks, send a stable attempt_id (1 to 128 letters, digits, . _ : or -) and reuse it on retries. Without attempt_id, we spot a retry by its exact email, and count each IP-only call as a separate signup. attempt_id deduplicates the signup counters only. Response:
200 {
"verdict": "block", // allow | challenge | block
"action": "allow", // what to do (see below)
"mode": "shadow", // shadow | enforce
"score": 100,
"check_id": "chk_...",
"reasons": [
{ "code": "disposable_domain", "detail": "...", "points": 100, "tier": "free",
"docs_url": "https://signupbouncer.com/docs/reasons#disposable_domain" }
],
"normalized": { "provider": null, "repeat_signups_7d": 0 },
"network": {
"asn": 14061, "asn_org": "...", "kinds": ["datacenter"],
"label": "...", "prefix": "203.0.113.0/24"
},
"shadow": [ { "code": "datacenter_ip", "tier": "starter", "detail": "..." } ],
"latency_ms": 2.1,
"quota_exceeded": false
}actionis what to do. In enforce mode it is the same asverdict. In shadow mode it is always"allow". Refuse the signup only whenactionis"block". When it is"challenge", ask for an extra step, such as email confirmation or a CAPTCHA, or let the signup through.shadowlists the reasons we would also have counted on a higher plan. They don't change the score.docs_urlon each reason and eachshadowentry links to that code's row on our Reason codes page. Use the link in your logs and tools, not in the message you show the person signing up.suggestionappears only when the email's domain is one typo from a big mail provider's and has no MX record or a null MX. Any mail server, such as naber.com's, means no suggestion, unless it serves throwaway or parked domains. A website-only domain counts as having no MX record, so its real users may see a suggestion. We skip it when the MX lookup takes over 150 ms or the typo fits two mail domains, as yahoo.cm fits yahoo.com and yahoo.ca. An uncached first lookup can take that long; test ann@gmial.com twice. The email typo checker lists the typos we miss. Forann@gmial.com,suggestionis"ann@gmail.com". It doesn't change the score or billing. Our default block message asks "Did you mean ann@gmail.com?". Ask the same way in yours, and let the person fix it.block_messageappears only whenactionis"block"or"challenge". It holds the message you set on your dashboard's Rules tab ("Block message"). While you keep our default, the words fit the reason (see What your users see). Showblock_messageonly when you refuse the signup.why_urlappears on those answers only when you turn on the "Why was this refused?" link on the Rules tab. It links to our page about the email's domain. Show the link only when you refuse the signup. See A "Why was this refused?" link.- On the Free plan, once you've used your monthly checks, the answer is
"action": "allow"with"quota_exceeded": true. We check nothing and bill nothing. - On monthly paid plans, we keep checking past the allowance (
"quota_exceeded": false). We bill each extra check at $2 per 1,000, up to your account's monthly cap on extra checks. At that cap, and on yearly plans past the allowance, the answer is again"action": "allow"with"quota_exceeded": true. We don't check or bill those signups. networkisnullwithout a usable IP. If something fails on our side, the answer is still a complete200with"action": "allow"and"error": "internal_error".ip_ignoredistruewhenipisn't a single IP address. Send the person's IP address, or leave outip.- Give the call a short timeout and let the signup through if it fails or times out.
Errors
Each error is JSON with an error code and a message that says what to do.
401 invalid_api_key: the key is missing, wrong, revoked, or was rotated more than 24 hours ago.400 invalid_body: the body is not a JSON object, has neither an email nor an IP, or has a badauth_providerorattempt_id.403 app_mismatch:appholds the id of an app the key wasn't made for.404 unknown_app: with an older all-apps key, theappid isn't on this key's account, or someone removed the app during the check.413 body_too_large: the body is over 64 KB.404 not_found: any path under/v1/other thanPOST /v1/checkand the Rules API.
Second look at confirmation
When we mark a signup challenge, the person still has to confirm their email through your own confirmation mail. Send us the confirmation, and we look at the signup again. We check the network and the browser or script that pressed the confirm button. We also count the signups from that network since the first check. The answer is allow or block, never challenge.
The second look is part of the original check. We never charge it as another check, and it works on every plan. We send no mail, and nothing in your flow links to our domain.
POST https://signupbouncer.com/v1/confirm
Authorization: Bearer sb_live_...
Content-Type: application/json
{ "check_id": "chk_...", "ip": "198.51.100.4", "user_agent": "Mozilla/5.0 ...", "via": "button" }check_idis the id/v1/checkanswered. Without it, sendsupabase_user_id(the Supabase user's id) oremail(the confirmed address). We look among the signups of the last 72 hours.- With
email, we may find several signups for the address: a retry, a duplicate, or a signup your auth platform turned down after our check, such as for a short password. If we already gave one of them a second look, that answer stands. Otherwise, when we allowed one of them, we answer allow with no second look. That way we never refuse an allowed signup because of another attempt. Sendcheck_idto get the second look for exactly the signup that created the user. ipanduser_agentare the IP and the browser of the request that confirmed the address. Send at least one.viasays how the person confirmed:"button"on a page of yours,"code"for a typed code, or"link"(the default) when the click on the emailed link confirms by itself. Mail scanners open links too, so on"link"we don't score the IP or the browser.confirmed_atis optional and holds when the person confirmed, in ISO 8601. Without it, we use the time your call arrives.
200 {
"verdict": "block", // allow | block
"action": "block", // what to do: always "allow" in shadow mode
"mode": "enforce",
"score": 85,
"check_id": "chk_...",
"first": { "verdict": "challenge", "score": 45 },
"reasons": [
{ "code": "free_subdomain_mail", "detail": "...", "points": 45, "tier": "free" },
{ "code": "confirm_from_datacenter", "detail": "...", "points": 40, "tier": "free" }
],
"shadow": [],
"second_look": true,
"replayed": false,
"latency_ms": 6.2,
"block_message": "..."
}- Refuse the confirmation only when
actionis"block". As on/v1/check, it is always"allow"in shadow mode, andverdictsays what enforce mode would do. Your dashboard counts the confirmations that enforce mode would have refused as a number of their own. - When
actionis"block", the answer also hasblock_message, the message you set on your dashboard's Rules tab. While you keep our default message,block_messagereads "We couldn't confirm your email address. Please contact us if you need help." Show the personblock_messagewhen you refuse the confirmation. - We give a second look only to a signup we marked challenge that went through. For any other check, the answer repeats its first result as allow or block, with
"second_look": false. - We take a second look at each check only once. We answer the first report, and give every later report the same answer with
"replayed": true, even when it comes from another IP. reasonsholds the first check's reasons and the new ones. A block needs a new reason that counts toward it:confirm_from_datacenterwhen someone pressed the button from a hosting network, orconfirm_ua_headlesswhen a script pressed it. A VPN, Tor or a change of network adds points but never blocks on its own. On the Pro and Scale plans, signup bursts since the check (theburst_codes) also count, and never block. See Reason codes. As on/v1/check, each reason'sdocs_urllinks to its row on the reason codes page.- We record how long the person took to confirm. It adds no points and changes no answer.
- When a report looks like a mail scanner opening the link, we answer
"action": "allow"withconfirm_scannerin the reasons, and keep nothing from the report. We still take the second look when the person confirms. - If something fails on our side, the answer is a complete
200with"action": "allow"and"error": "internal_error". We answer within half a second. Give the call a timeout of 0.8 to 1 second, and let the confirmation through when it fails, times out or answers with a 4xx.
Errors
400 invalid_body: nocheck_id,supabase_user_idoremail, or neitheripnoruser_agent.401 invalid_api_key,403 app_mismatchand413 body_too_large, as for/v1/check.404 unknown_check: no check with this id on your key's apps, no signup of that address, or no challenged signup of that Supabase user in the last 72 hours.410 check_expired: the check is more than 72 hours old.
Supabase
When our Supabase hook marks a signup challenge, we keep a keyed hash of the new user's id for 72 hours, and delete it within 6 hours after that. Send the user's id as supabase_user_id from your own /auth/confirm route:
- In Supabase, under Authentication, open the "Confirm signup" email template. Set its link to
{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email. - Save the files below as
lib/signupbouncer-fetch.tsandapp/auth/confirm/route.ts. The route uses thecreateClientfrom Supabase's Next.js guide. - Set
SIGNUPBOUNCER_API_KEYandSUPABASE_SERVICE_ROLE_KEYin your server's environment.
The link opens a page with a button. The button confirms the address with Supabase, then sends us the user's id. On a block, the route signs the person out and bans the user. Supabase has already marked the address confirmed by then. The page's button only works in the browser that opened the link, so another site can't confirm for the person.
Your /auth/confirm route sees only the people who confirm through it. Supabase's own verify endpoint is public. Someone who scripts the confirmation can send the token from the email straight to Supabase with your public anon key, and skip your route and the second look. The second look catches people and farms that click through the email as sent.
lib/signupbouncer-fetch.ts
// SignupBouncer signup check. Docs: https://signupbouncer.com/docs/code/nextjs
// 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;
}
}
app/auth/confirm/route.ts
// SignupBouncer signup check. Docs: https://signupbouncer.com/docs/api#confirm-supabase
// app/auth/confirm/route.ts: Supabase email confirmation with a second look from SignupBouncer.
// In Supabase, under Authentication, Emails, "Confirm signup", set the link to
// {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email
// The link opens a page with a button, because mail scanners open links but don't press buttons. The button posts
// back here: we confirm the address with Supabase, then report the confirmation to SignupBouncer with the user's id.
// On a block (enforce mode only), we sign the person out and ban the user, which needs SUPABASE_SERVICE_ROLE_KEY on
// the server. Any failure, timeout or error answer lets the confirmation through.
// What it can't stop: Supabase's own verify endpoint is public. Someone who scripts the confirmation can send the
// token from the email straight to Supabase with your anon key, and never load this page. The second look sees the
// people who confirm through it.
import { createClient as createAdminClient, type EmailOtpType } from "@supabase/supabase-js";
import { createClient } from "@/utils/supabase/server";
import { confirmationRefusal } from "@/lib/signupbouncer-fetch";
const COOKIE = "sb_confirm";
const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
const page = (body: string, status = 200, headers: Record<string, string> = {}) =>
new Response(
`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Confirm</title>${body}`,
{ status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...headers } },
);
/** A path on this site only: anything that would leave it ("//host", "/\host", another origin) becomes "/". */
function nextPath(v: unknown, requestUrl: string): string {
if (typeof v !== "string" || !v.startsWith("/") || v.includes("\\")) return "/";
try {
const here = new URL(requestUrl);
const to = new URL(v, here);
return to.origin === here.origin ? to.pathname + to.search + to.hash : "/";
} catch {
return "/";
}
}
/**
* The button page. Its form carries a random value that is also set as a SameSite=Strict cookie, so only a press of
* this page's button in the person's own browser confirms: another site can't post the form for them.
*/
function buttonPage(request: Request, fields: { token_hash: string; type: string; next: string }, status = 200) {
const nonce = crypto.randomUUID();
const secure = new URL(request.url).protocol === "https:" ? "; Secure" : "";
const inputs = Object.entries({ ...fields, nonce })
.map(([k, v]) => `<input type="hidden" name="${k}" value="${esc(v)}">`).join("");
return page(`<form method="post">${inputs}<button type="submit">Confirm my email</button></form>`, status, {
"set-cookie": `${COOKIE}=${nonce}; Path=/auth/confirm; HttpOnly; SameSite=Strict; Max-Age=3600${secure}`,
});
}
function cookieValue(request: Request, name: string): string | null {
for (const part of (request.headers.get("cookie") ?? "").split(";")) {
const [k, ...v] = part.trim().split("=");
if (k === name) return v.join("=");
}
return null;
}
export function GET(request: Request) {
const q = new URL(request.url).searchParams;
return buttonPage(request, {
token_hash: q.get("token_hash") ?? "",
type: q.get("type") ?? "email",
next: nextPath(q.get("next"), request.url),
});
}
export async function POST(request: Request) {
const form = await request.formData();
const fields = {
token_hash: String(form.get("token_hash") ?? ""),
type: String(form.get("type") ?? "email"),
next: nextPath(form.get("next"), request.url),
};
// Only this page's own button: the same origin, and the value it set in the person's browser.
const origin = request.headers.get("origin");
const nonce = String(form.get("nonce") ?? "");
if ((origin && origin !== new URL(request.url).origin) || !nonce || cookieValue(request, COOKIE) !== nonce) {
return buttonPage(request, fields, 403);
}
const supabase = await createClient();
const { data, error } = await supabase.auth.verifyOtp({
token_hash: fields.token_hash,
type: fields.type as EmailOtpType,
});
if (error || !data.user) return Response.redirect(new URL("/error", request.url), 303);
const refusal = await confirmationRefusal({
supabaseUserId: data.user.id,
// On Vercel, x-real-ip holds the client IP. On other hosts, use the header your host sets.
ip: request.headers.get("x-real-ip") ?? undefined,
userAgent: request.headers.get("user-agent") ?? undefined,
via: "button",
});
if (refusal) {
await supabase.auth.signOut();
try {
const admin = createAdminClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
await admin.auth.admin.updateUserById(data.user.id, { ban_duration: "876000h" });
} catch {
// Without the service role key the user stays confirmed but signed out.
}
return page(`<p>${esc(refusal)}</p>`, 403);
}
return Response.redirect(new URL(fields.next, request.url), 303);
}
Remove the confirmation route
In Supabase, set the link in the "Confirm signup" email template back to {{ .ConfirmationURL }}, then delete app/auth/confirm/route.ts. Sign up with a test address and confirm it. The confirmation goes through, and no second look appears on your Checks tab.
Removing the route doesn't unban anyone. A user the route banned stays banned until you lift the ban. To lift a ban, update the user with ban_duration: "none" and your service role key:
import { createClient } from "@supabase/supabase-js";
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
await admin.auth.admin.updateUserById(userId, { ban_duration: "none" });Other platforms
Call POST /v1/confirm from the server code that finishes the confirmation, before you mark the address confirmed. Keep the check_id from /v1/check with the user, or send the address. In lib/signupbouncer-fetch.ts, confirmationRefusal makes the call with a short timeout and returns the message when you should refuse the confirmation.
Rules API
With an API key, you can read and change an app's Rules settings from your code, as on the Rules tab of your dashboard. Rules API calls are not checks. We don't bill them, and they don't count toward your monthly checks.
GET https://signupbouncer.com/v1/apps/app_.../rules
Authorization: Bearer sb_live_...The answer holds the settings and a read-only context with your plan, the checks that need a higher plan, the values each check takes, the defaults and the limits.
200 {
"app": { "id": "app_...", "name": "My app" },
"mode": "shadow", // shadow | enforce
"challenge_at": 40,
"block_at": 70,
"challenge_action": "allow", // what the Supabase and Ory hooks do with a challenge: allow | block
"block_message": "Please sign up with a permanent email address.",
"why_link": false,
"velocity": { "subnet": { "app": 5, "account": 10 }, "asn": { "app": 10, "account": 20 },
"inbox": { "app": 2 }, "pattern": { "app": 4, "account": 8 },
"domain": { "app": 5, "account": 10 } },
"checks": { "disposable_domain": "default", "role_account": "default", "vpn_ip": "default", ... },
"context": { "plan": "free", "locked_checks": ["invalid_syntax", "no_mx"], ... }
}To change settings, send only the ones you want to change. The others stay as they are.
PATCH https://signupbouncer.com/v1/apps/app_.../rules
Authorization: Bearer sb_live_...
Content-Type: application/json
{ "block_at": 80, "checks": { "role_account": "block" } }The answer is the app's rules after the change. Its changed field lists the settings that changed, such as "block_at" or "checks.role_account".
- A change applies to the next signup, in the Supabase hook and in
/v1/check. Setting"mode": "enforce"starts refusing real signups marked block at once, and"shadow"stops refusing them. - We check each value the way the Rules tab does. If one value is wrong, we change nothing and answer
400 invalid_body, withfieldset to the wrong one, such as"block_at"or"velocity.subnet.account". checkstakes"default","off","challenge"or"block"per check, as on the Check settings card. A check above your plan keeps its setting. You can send that setting back as it is, and you can change it after you upgrade.- Velocity numbers are 0 (off) or 2 to 10,000. Each all-apps number (
account) is 0 or at least the app's number. The inbox rule counts this app only, so it takesappalone. - Overrides stay on the dashboard.
Which apps a key can change
A key made for an app reads and changes only that app. Any other app id gets 403 app_mismatch.
An older key, labeled "all apps (older key)" on the dashboard, reads and changes every app of your account, because it already checks signups for all of them. Treat it as a key for the whole account. To limit a key to one app, create a key for that app and revoke the older one.
Rules API errors
401 invalid_api_key,403 app_mismatchand413 body_too_large, as for/v1/check.404 unknown_app: your account has no app with this id.400 invalid_body: the body is not a JSON object, or a setting is unknown or wrong.fieldholds its name.429 rate_limited: rules calls allow 60 a minute and 2,000 a day per account, shared with the MCP rules tools.retry_after_secondsand theRetry-Afterheader say when to try again.