{
  "openapi": "3.1.0",
  "info": {
    "title": "signupbouncer API",
    "version": "1.0.0",
    "summary": "Check a new signup's email address and IP before you create the user.",
    "description": "Get a key at https://signupbouncer.com/signup (the Free plan needs no card). Call POST /v1/check from your server with a timeout of about 1.5 seconds, act on `action`, and let the signup through when the call fails. Docs: https://signupbouncer.com/docs. Code per stack: https://signupbouncer.com/docs#code. For AI assistants: https://signupbouncer.com/llms-full.txt and the MCP server at https://signupbouncer.com/mcp."
  },
  "servers": [
    {
      "url": "https://signupbouncer.com"
    }
  ],
  "externalDocs": {
    "url": "https://signupbouncer.com/docs"
  },
  "components": {
    "schemas": {
      "CheckRequest": {
        "type": "object",
        "description": "Send a nonempty email, a nonempty ip, or both. The other fields are optional. Unknown fields are ignored.",
        "anyOf": [
          {
            "required": [
              "email"
            ],
            "properties": {
              "email": {
                "minLength": 1
              }
            }
          },
          {
            "required": [
              "ip"
            ],
            "properties": {
              "ip": {
                "minLength": 1
              }
            }
          }
        ],
        "properties": {
          "email": {
            "type": "string",
            "maxLength": 320,
            "description": "The address the person signed up with."
          },
          "ip": {
            "type": "string",
            "maxLength": 64,
            "description": "The client's IPv4 or IPv6 address, from a header your host sets. Leave it out if unsure."
          },
          "user_agent": {
            "type": "string",
            "maxLength": 512
          },
          "attempt_id": {
            "type": "string",
            "pattern": "^[A-Za-z0-9._:-]{1,128}$",
            "description": "Stable signup attempt id, reused on retries. Recommended for IP-only checks. Scoped to this app and address. Without it, the exact email identifies the attempt, or each IP-only call counts separately. Deduplicates signup counters, not billing or responses."
          },
          "app": {
            "type": "string",
            "pattern": "^app_[0-9a-f]{24}$",
            "description": "Leave it out. An API key belongs to one app, and its checks go to that app. If you send it, it must be the key's own app id, and any other value gets 403 app_mismatch. Only older keys, made before keys had an app and labelled \"all apps (older key)\" on the dashboard, use it to pick the app. Without it they use your first app."
          },
          "auth_provider": {
            "type": "string",
            "pattern": "^[A-Za-z0-9._:-]{1,40}$",
            "description": "For OAuth and SSO signups: the provider id as your auth library names it (\"google\", \"github\", \"google.com\", \"microsoft-entra-id\", or an SSO id such as \"sso:<id>\" or \"oidc.<id>\"). Send it with email_verified. When the id is an OAuth or SSO provider we know and email_verified is true, only the IP and signup bursts are scored. Any other id, such as an email or password provider (\"nodemailer\", \"credentials\"), is checked like an email signup."
          },
          "email_verified": {
            "type": "boolean",
            "description": "With auth_provider: true when your auth platform says the provider verified this address (an OIDC email_verified claim, Firebase's emailVerified, Supabase's user_metadata.email_verified). Take it from the platform, never from what the user typed. Missing or false: the email is checked like an email signup."
          }
        }
      },
      "Reason": {
        "type": "object",
        "required": [
          "code",
          "detail",
          "points",
          "tier"
        ],
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "invalid_syntax",
              "disposable_domain",
              "burner_mx",
              "no_mx",
              "free_subdomain_mail",
              "repeat_inbox",
              "relay_service",
              "role_account",
              "plus_alias",
              "datacenter_ip",
              "vpn_ip",
              "tor_exit",
              "privacy_relay",
              "residential_proxy",
              "burst_subnet",
              "burst_asn",
              "burst_inbox",
              "burst_pattern",
              "override_allow",
              "override_block"
            ]
          },
          "detail": {
            "type": "string"
          },
          "points": {
            "type": "number"
          },
          "tier": {
            "type": "string",
            "enum": [
              "free",
              "starter",
              "pro",
              "scale"
            ],
            "description": "The lowest plan that counts this reason."
          }
        }
      },
      "ShadowReason": {
        "type": "object",
        "required": [
          "code",
          "detail",
          "tier"
        ],
        "description": "A reason a higher plan would also have counted. It adds nothing to the score.",
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "invalid_syntax",
              "disposable_domain",
              "burner_mx",
              "no_mx",
              "free_subdomain_mail",
              "repeat_inbox",
              "relay_service",
              "role_account",
              "plus_alias",
              "datacenter_ip",
              "vpn_ip",
              "tor_exit",
              "privacy_relay",
              "residential_proxy",
              "burst_subnet",
              "burst_asn",
              "burst_inbox",
              "burst_pattern",
              "override_allow",
              "override_block"
            ]
          },
          "detail": {
            "type": "string"
          },
          "tier": {
            "type": "string",
            "enum": [
              "free",
              "starter",
              "pro",
              "scale"
            ]
          }
        }
      },
      "Network": {
        "type": [
          "object",
          "null"
        ],
        "description": "The IP's network. null without a usable IP.",
        "required": [
          "asn",
          "asn_org",
          "kinds",
          "label",
          "prefix"
        ],
        "properties": {
          "asn": {
            "type": [
              "integer",
              "null"
            ]
          },
          "asn_org": {
            "type": [
              "string",
              "null"
            ]
          },
          "kinds": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "datacenter",
                "vpn",
                "tor",
                "privacy_relay",
                "residential_proxy"
              ]
            }
          },
          "label": {
            "type": [
              "string",
              "null"
            ]
          },
          "prefix": {
            "type": "string",
            "description": "The /24 (IPv4) or /48 (IPv6) the IP is in."
          }
        }
      },
      "CheckResult": {
        "type": "object",
        "required": [
          "verdict",
          "action",
          "mode",
          "score",
          "check_id",
          "reasons",
          "normalized",
          "network",
          "shadow",
          "latency_ms",
          "quota_exceeded"
        ],
        "properties": {
          "verdict": {
            "type": "string",
            "enum": [
              "allow",
              "challenge",
              "block"
            ],
            "description": "What the score says."
          },
          "action": {
            "type": "string",
            "enum": [
              "allow",
              "challenge",
              "block"
            ],
            "description": "What to do with the signup: same as \"verdict\" in enforce mode, always \"allow\" in shadow mode, over the monthly allowance or cap (\"quota_exceeded\": true) and when our side failed (\"error\": \"internal_error\")."
          },
          "mode": {
            "type": "string",
            "enum": [
              "shadow",
              "enforce"
            ]
          },
          "score": {
            "type": "number"
          },
          "check_id": {
            "type": "string",
            "pattern": "^chk_[0-9a-f]+$"
          },
          "reasons": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Reason"
            }
          },
          "normalized": {
            "type": "object",
            "required": [
              "provider",
              "repeat_signups_7d"
            ],
            "properties": {
              "provider": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "\"gmail\", \"outlook\", \"icloud\"... or null."
              },
              "repeat_signups_7d": {
                "type": "integer",
                "description": "Earlier checks of the same normalised inbox on your account in the last 7 days."
              }
            }
          },
          "suggestion": {
            "type": "string",
            "description": "Present only when the email's domain looks like a typo of a big mail provider: the address with the domain corrected (\"ann@gmial.com\" gives \"ann@gmail.com\"). Show it to the person signing up as a question, and never change the address for them. It does not change the score or billing."
          },
          "block_message": {
            "type": "string",
            "description": "Present only when \"action\" is \"block\" or \"challenge\": the message you set on the Rules tab of your dashboard (\"Message a blocked user sees\"). Show it to the person when you refuse the signup."
          },
          "network": {
            "$ref": "#/components/schemas/Network"
          },
          "shadow": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ShadowReason"
            }
          },
          "latency_ms": {
            "type": "number"
          },
          "quota_exceeded": {
            "type": "boolean",
            "description": "true: nothing was checked or billed, because the account is past its allowance or its cap."
          },
          "error": {
            "type": "string",
            "enum": [
              "internal_error"
            ],
            "description": "Present only when our side failed. The answer is then a complete \"allow\"."
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error",
          "message"
        ],
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "invalid_api_key",
              "invalid_body",
              "app_mismatch",
              "unknown_app",
              "body_too_large",
              "not_found"
            ]
          },
          "message": {
            "type": "string",
            "description": "What went wrong and what to do about it, in plain English."
          }
        }
      },
      "SupabaseHookAnswer": {
        "type": "object",
        "description": "{} lets Supabase create the user. An error object refuses the signup with your app's block message.",
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "http_code",
              "message"
            ],
            "properties": {
              "http_code": {
                "type": "integer",
                "enum": [
                  400
                ]
              },
              "message": {
                "type": "string"
              }
            }
          }
        }
      },
      "Status": {
        "type": "object",
        "required": [
          "status",
          "checked_at",
          "components",
          "data",
          "incidents"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "operational",
              "degraded",
              "down",
              "maintenance"
            ]
          },
          "checked_at": {
            "type": "string",
            "format": "date-time"
          },
          "components": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "name",
                "status",
                "detail"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "enum": [
                    "api",
                    "hook",
                    "data"
                  ]
                },
                "name": {
                  "type": "string"
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "operational",
                    "degraded",
                    "down",
                    "maintenance"
                  ]
                },
                "detail": {
                  "type": "string"
                }
              }
            }
          },
          "data": {
            "type": "object",
            "required": [
              "version",
              "published_at",
              "age_minutes"
            ],
            "properties": {
              "version": {
                "type": "string"
              },
              "published_at": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "age_minutes": {
                "type": [
                  "number",
                  "null"
                ]
              }
            }
          },
          "incidents": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "title",
                "impact",
                "components",
                "started",
                "resolved",
                "updates"
              ],
              "properties": {
                "title": {
                  "type": "string"
                },
                "impact": {
                  "type": "string",
                  "enum": [
                    "minor",
                    "major",
                    "maintenance"
                  ]
                },
                "components": {
                  "type": "array",
                  "items": {
                    "type": "string",
                    "enum": [
                      "api",
                      "hook",
                      "data"
                    ]
                  }
                },
                "started": {
                  "type": "string"
                },
                "resolved": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "updates": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "required": [
                      "at",
                      "text"
                    ],
                    "properties": {
                      "at": {
                        "type": "string"
                      },
                      "text": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "An API key, sb_live_... The account owner creates one per app on the dashboard's Integration tab. It is shown once, when it is created or rotated. A rotated key keeps working for 24 hours."
      }
    }
  },
  "paths": {
    "/v1/check": {
      "post": {
        "operationId": "checkSignup",
        "summary": "Check one signup",
        "description": "Scores the email address and IP of one signup and answers allow, challenge or block, with reasons. Each checked signup counts toward your monthly allowance. If something fails on our side, the answer is still a complete 200 with \"action\": \"allow\" and \"error\": \"internal_error\".",
        "security": [
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CheckRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The check's answer.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CheckResult"
                }
              }
            }
          },
          "400": {
            "description": "invalid_body: the body is not a JSON object, has neither email nor ip, or a bad auth_provider or attempt_id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "invalid_api_key: the Authorization header is missing, or the key is wrong, revoked, or was rotated more than 24 hours ago.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "app_mismatch: the key belongs to one app and the app field holds another app's id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "unknown_app: with an older all-apps key, the app id is not on this key's account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "body_too_large: the body is over 64 KB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/hook/supabase/{appId}": {
      "post": {
        "operationId": "supabaseBeforeUserCreated",
        "summary": "Supabase Before User Created hook",
        "description": "Supabase calls this URL. You paste it and its secret into Supabase (Authentication, Hooks) and never call it yourself. Calls are signed with Standard Webhooks headers and the app's hook secret. The answer is always 200 JSON: {} allows the signup, and when the signature is wrong, the app is unknown or our side fails, the answer is {} as well.",
        "parameters": [
          {
            "name": "appId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Supabase's hook payload."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Allow ({}) or refuse.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupabaseHookAnswer"
                }
              }
            }
          }
        }
      }
    },
    "/status.json": {
      "get": {
        "operationId": "status",
        "summary": "Service status",
        "description": "The state of the check API, the Supabase hook and the burner and IP data, and recent incidents.",
        "responses": {
          "200": {
            "description": "Current status.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Status"
                }
              }
            }
          }
        }
      }
    }
  }
}