API reference
The base URL is https://api.sealgate.jp/v1. Requests and responses are JSON, and keys are snake_case.
Overview
Passkey registration and login both take two steps: get options, run the authenticator in the browser, then send the response to verify. The browser SDK handles the browser side. Your server forwards the JSON from the SDK to SealGate with your API key attached. The API key never reaches the browser.
- Browser: sg.login() → your server (optionsUrl)
- Your server: POST /v1/login/options → returns challenge_id and options to the SDK
- Browser: the authenticator signs → your server (verifyUrl)
- Your server: POST /v1/login/verify → receives external_id and issues its own session
Authentication
Send an API key issued in the dashboard in the Authorization header. Keys are per project environment (Live or Test, one rp_id each). The API calls an environment an app. Live keys start with sk_live_ and Test keys with sk_test_. Users in Test environments do not count toward billing (MAU).
Authorization: Bearer sk_live_...
Content-Type: application/jsonRegistration
POST/v1/register/options
| Name | Type | Description |
|---|---|---|
external_idRequired | string | Your user ID (up to 200 characters). SealGate stores external_id and display_name, so use an opaque ID that does not identify the person (such as an internal number or a random ID) rather than an email address |
display_nameRequired | string | Name shown in the authenticator dialog (email address or nickname) |
signed_in | boolean | Set to true to add a passkey for a user who already has one. Set it only after your server has confirmed the user is signed in |
recovery_token | string | Recovery token for a user who lost their device and is registering again (issued by /v1/recovery/start) |
For a user who already has a passkey, either signed_in: true or recovery_token is required. Without either, the API returns 403 (recovery_not_allowed). Decide signed_in from your server session, not from a value sent by the browser. Existing passkeys are listed in options.excludeCredentials, so the authenticator refuses to register the same user twice. authenticatorSelection.residentKey and userVerification come from the app settings (resident_key and user_verification). attestation is always none: SealGate does not request or verify attestation of the authenticator model. The aaguid in the device list is the value the authenticator reported, and SealGate does not verify it.
Response (options is a WebAuthn PublicKeyCredentialCreationOptions):
{
"challenge_id": "chl_01j8x2k9m4n5p6q7r8s9t0v1w2",
"end_user_id": "eu_01j8x2k9m4n5p6q7r8s9t0v1w2",
"options": {
"rp": { "id": "example.jp", "name": "Example" },
"user": { "id": "…", "name": "taro@example.jp", "displayName": "taro@example.jp" },
"challenge": "…",
"pubKeyCredParams": [{ "type": "public-key", "alg": -7 }, { "type": "public-key", "alg": -257 }],
"attestation": "none",
"authenticatorSelection": { "residentKey": "preferred", "userVerification": "preferred" },
"timeout": 300000
}
}POST/v1/register/verify
| Name | Type | Description |
|---|---|---|
challenge_idRequired | string | Value returned by options. Single use, expires after 5 minutes |
responseRequired | object | The RegistrationResponseJSON returned by the SDK (authenticator), as is |
name | string | Device name (up to 64 characters; longer values are truncated). The name passed to sg.register({ name }) in the SDK is included in the POST to verifyUrl, so your server forwards it here |
Response (201):
{
"credential_id": "crd_01j8x2k9m4n5p6q7r8s9t0v1w2",
"end_user_id": "eu_01j8x2k9m4n5p6q7r8s9t0v1w2"
}Login
POST/v1/login/options
| Name | Type | Description |
|---|---|---|
external_id | string | If set, only that user's passkeys are allowed. If omitted, the user picks from passkeys saved on the device (username-less login) |
Response (options is a PublicKeyCredentialRequestOptions):
{
"challenge_id": "chl_01j8x2k9m4n5p6q7r8s9t0v1w3",
"options": {
"rpId": "example.jp",
"challenge": "…",
"allowCredentials": [{ "type": "public-key", "id": "…", "transports": ["internal"] }],
"userVerification": "preferred",
"timeout": 300000
}
}POST/v1/login/verify
| Name | Type | Description |
|---|---|---|
challenge_idRequired | string | Value returned by options |
responseRequired | object | The AuthenticationResponseJSON returned by the SDK, as is |
Response. Your server issues the session from here:
{
"end_user_id": "eu_01j8x2k9m4n5p6q7r8s9t0v1w2",
"external_id": "user-123",
"credential_id": "crd_01j8x2k9m4n5p6q7r8s9t0v1w2",
"user_verified": true,
"password_allowed": true
}password_allowed is included only in the response to a successful passkey login. It depends only on the app's password login end date (password_coexist_until, set in the environment settings in the dashboard): false after that date, true if there is no date or it has not passed yet (it is not a per-user value). Password logins do not go through SealGate, so SealGate does not block them. To decide on the password form, have your server check password_coexist_until from GET /v1/apps/{app_id} and whether the user has an active passkey in GET /v1/users/{external_id}/credentials.
Devices (passkeys)
GET/v1/users/{external_id}/credentials
Response:
{
"end_user_id": "eu_01j8x2k9m4n5p6q7r8s9t0v1w2",
"data": [
{
"id": "crd_01j8x2k9m4n5p6q7r8s9t0v1w2",
"name": "iPhone",
"transports": ["internal", "hybrid"],
"aaguid": "fbfc3007-154e-4ecc-8c0b-6e020557d7bd",
"backup_eligible": true,
"backed_up": true,
"created_at": "2026-09-01T02:15:00Z",
"last_used_at": "2026-09-22T09:40:12Z",
"revoked_at": null
}
]
}PATCH/v1/credentials/{credential_id}
PATCH/v1/users/{external_id}/credentials/{credential_id}
| Name | Type | Description |
|---|---|---|
nameRequired | string | Device name (1–64 characters). Leading and trailing spaces and control characters are removed |
Renames a device. The response is one item in the same shape as data in the list. Returns 404 (not_found) for a revoked passkey, or, when external_id is given, for a passkey that does not belong to that user.
DELETE/v1/credentials/{credential_id}
Revokes one passkey.
POST/v1/users/{external_id}/revoke
Revokes all of a user's passkeys (for example, after an account takeover). The user and the audit log are kept.
{ "end_user_id": "eu_01j8x2k9m4n5p6q7r8s9t0v1w2", "revoked": 2 }DELETE/v1/users/{external_id}
Deletes a user (account closure or a personal data deletion request). Deletes passkeys, registration and login challenges, recovery tokens and the display name. The audit log keeps counts and timestamps, sets end_user_id to deleted, and clears the passkey ID, IP, User-Agent and detail. This cannot be undone. Registering again with the same external_id creates a different user.
{
"end_user_id": "eu_01j8x2k9m4n5p6q7r8s9t0v1w2",
"deleted": true,
"credentials_deleted": 2
}Recovery
Re-registration for a user who lost their device. Verify the user's identity on your side first, then call this API. SealGate only returns a recovery token valid for 15 minutes; it does not verify identity.
POST/v1/recovery/start
| Name | Type | Description |
|---|---|---|
external_idRequired | string | User ID |
reason | string | Reason recorded in the audit log (e.g. identity verified by support) |
Response. Pass recovery_token to /v1/register/options:
{
"recovery_token": "rt_…",
"expires_at": "2026-09-22T10:00:00Z"
}App settings
GET/v1/apps/{app_id}
Response. app_id must match the API key's app:
{
"id": "app_01j8x2k9m4n5p6q7r8s9t0v1w6",
"name": "Example",
"rp_id": "example.jp",
"rp_name": "Example",
"origins": ["https://example.jp"],
"resident_key": "preferred",
"user_verification": "preferred",
"password_coexist_until": null,
"webhook_url": null
}PATCH/v1/apps/{app_id}
| Name | Type | Description |
|---|---|---|
resident_key | string | residentKey requested from the authenticator at registration. required / preferred / discouraged (default preferred) |
user_verification | string | User verification (userVerification) requested from the authenticator at registration and login. required / preferred (default preferred) |
resident_key and user_verification are the settings you can change with an API key (pass at least one). Change rp_id, allowed origins, the webhook URL and the password login end date (password_coexist_until) in the environment settings in the dashboard (allowed origins and the webhook URL can also be changed with update_app over an MCP connection made by logging in (OAuth)). Changing rp_id makes registered passkeys unusable, because a passkey is bound to the rp_id it was registered with. Allowed origins must be https origins (http://localhost for development) on the rp_id domain or its subdomains, so registration and login from Android apps (android:apk-key-hash: origins) are not supported. Set resident_key to required if you only use username-less login or autofill. With user_verification set to required, registrations and logins where the user was not verified with biometrics or a PIN are rejected with verification_failed. You can also change both in the environment settings in the dashboard.
Audit log and stats
GET/v1/apps/{app_id}/audit
| Name | Type | Description |
|---|---|---|
event | string | Filter by event type (e.g. login.succeeded). An unknown event returns 400 invalid_request |
since | string | On or after this time (ISO 8601). A value that is not a date returns 400 invalid_request |
limit | number | Number of entries (default 200, max 1000) |
Response (newest first). ip and user_agent are the values of the request SealGate received. For API calls they are your relaying server's IP and User-Agent (node for Node.js fetch), not the user's device (the SDK does not send the user's values to SealGate either). For operations in the dashboard or over MCP, they are the operator's. If you need the user's device information, record it on your server:
{
"data": [
{
"id": "log_01j8x2k9m4n5p6q7r8s9t0v1w5",
"event": "login.succeeded",
"ok": true,
"end_user_id": "eu_01j8x2k9m4n5p6q7r8s9t0v1w2",
"credential_id": "crd_01j8x2k9m4n5p6q7r8s9t0v1w2",
"ip": "198.51.100.7",
"user_agent": "node",
"detail": null,
"at": "2026-09-22T09:40:12Z"
}
]
}GET/v1/apps/{app_id}/stats
Response. app_id must match the API key's app. mau and registrations are per-month arrays for the last 6 months (UTC calendar months, oldest first; the current month is partial): mau is the number of users who logged in that month, and registrations is the number of completed registrations. logins counts successful and failed logins in the last 30 days, and users is the number of users and of users with an active passkey. transports counts active passkeys per transport, most first (a passkey with several transports counts once for each; passkeys that reported none count as unknown):
{
"mau": [
{ "month": "2026-04", "users": 812 },
{ "month": "2026-05", "users": 903 },
{ "month": "2026-06", "users": 1240 },
{ "month": "2026-07", "users": 1388 },
{ "month": "2026-08", "users": 1502 },
{ "month": "2026-09", "users": 1097 }
],
"registrations": [
{ "month": "2026-04", "count": 96 },
{ "month": "2026-05", "count": 120 },
{ "month": "2026-06", "count": 305 },
{ "month": "2026-07", "count": 188 },
{ "month": "2026-08", "count": 142 },
{ "month": "2026-09", "count": 61 }
],
"logins": { "succeeded": 4210, "failed": 38 },
"users": { "total": 1830, "with_passkey": 1502 },
"transports": [
{ "name": "internal", "count": 1420 },
{ "name": "hybrid", "count": 61 },
{ "name": "usb", "count": 21 }
]
}Webhooks
Set a webhook URL (https) in the environment settings in the dashboard, and SealGate sends events from the API (/v1) as JSON POSTs through Webhook Admin. The events and their data are in the table below. The same operations in the dashboard or over MCP (revoking or renaming a passkey, revoking or deleting a user, issuing a recovery token) send the same events with the same data as the API. Webhook Admin retries failed deliveries. Delivery logs and the signing secret are on the Webhook Admin page opened from "Deliveries and signing secret" in the environment settings (get_webhook_portal over MCP).
POST <webhook_url>
content-type: application/json
webhook-id: msg_...
webhook-timestamp: 1790000000
webhook-signature: v1,<base64>
{ "type": "login.succeeded", "timestamp": "2026-09-22T00:00:00.000Z",
"data": { "app_id": "app_...", "end_user_id": "eu_...", "external_id": "user-123", "credential_id": "crd_..." } }| type | data | Sent when |
|---|---|---|
register.completed | { app_id, end_user_id, credential_id } | A registration completed with POST /v1/register/verify |
register.failed | { app_id, code } | POST /v1/register/verify failed to verify the response. code is the API error code (usually verification_failed; origin_not_allowed for an origin mismatch). Not sent when the API returns 410 because the challenge expired, was used or is unknown |
login.succeeded | { app_id, end_user_id, external_id, credential_id } | A login succeeded with POST /v1/login/verify |
login.failed | { app_id, code } | POST /v1/login/verify failed. code is verification_failed, origin_not_allowed, credential_revoked, mau_limit or similar. Not sent on 410 (challenge_expired) |
credential.revoked | { app_id, end_user_id, credential_id } | A passkey was revoked with DELETE /v1/credentials/{credential_id}, in the dashboard or with revoke_credential over MCP |
user.revoked | { app_id, end_user_id, revoked } | All of a user's passkeys were revoked with POST /v1/users/{external_id}/revoke, in the dashboard or with revoke_user over MCP. revoked is the number of passkeys revoked |
user.deleted | { app_id, end_user_id, credentials_deleted } | A user was deleted with DELETE /v1/users/{external_id}, in the dashboard or with delete_user over MCP |
recovery.issued | { app_id, external_id, reason } | A recovery token was issued with POST /v1/recovery/start or issue_recovery_token over MCP. reason is null if not given |
credential.renamed | { app_id, end_user_id, credential_id } | A device name was changed with PATCH /v1/credentials/{credential_id}, PATCH /v1/users/{external_id}/credentials/{credential_id} or rename_credential over MCP. The new name is not included (read it from the passkey list) |
The signature is in the webhook-id, webhook-timestamp and webhook-signature headers. Verify the raw body with Webhook from the webhookadmin package on npm. type is the event and data holds its fields. webhook-id is unique per event and stays the same across retries, so use it to drop duplicates.
// npm install webhookadmin
import { Webhook } from 'webhookadmin';
const wh = new Webhook(process.env.SEALGATE_WEBHOOK_SECRET); // whsec_...
// Next.js (App Router): app/sealgate/webhook/route.ts
export async function POST(req) {
let body;
try {
body = await wh.verify(await req.text(), req.headers); // { type, timestamp, data }
} catch {
return new Response(null, { status: 400 });
}
const { type, data } = body; // e.g. type: login.succeeded, data: { app_id, end_user_id, external_id, credential_id }
return new Response(null, { status: 204 });
}Signature verification (Webhook Admin)
Rate limits
Requests per minute: 600 per API key on Free and 3,000 on usage-based and Enterprise. All API keys of an organization together: 1,200 on Free and 6,000 on usage-based and Enterprise. Over the limit, the API returns 429 (rate_limited) with a Retry-After header. An MCP batch can contain up to 20 requests, and each one counts. If too many dashboard login link emails are requested in a short time, sending is paused for a while.
MCP (AI agents)
MCP-compatible agents (Claude, Claude Code, Cursor, Codex and others) can operate SealGate. Add the MCP URL to your client, and the browser opens the dashboard login (email, Google, GitHub or passkey) and a consent screen. The agent then acts as the account you approved (OAuth 2.1). New users can sign up from that login screen. What the agent can do is set by the same roles as the dashboard (viewer / developer / admin / owner).
MCP: https://api.sealgate.jp/v1/mcp (Streamable HTTP, POST)
Skill: https://sealgate.jp/skill/SKILL.md
Plugin (Claude Code):
/plugin marketplace add https://sealgate.jp/plugin/marketplace.json
/plugin install sealgate@sealgate
Connect only:
claude mcp add --transport http sealgate https://api.sealgate.jp/v1/mcpTools with login (OAuth)
Call list_organizations (or create_organization if there is none), then list_projects / list_environments to get the environment ID (app_id), and pass it to environment tools. Pass organization_id to organization tools.
| Name | Type | Description |
|---|---|---|
Organizations and projects | organization_id | whoami / list_organizations / create_organization / list_projects / list_environments / list_members / invite_member (admin or above) |
Organization tools | organization_id | create_app (admin or above) / list_api_keys / revoke_api_key (developer or above; runs with confirm: true) / get_account / create_checkout, get_billing_portal (owner) |
Environment tools | app_id | get_app / list_users / list_credentials / list_audit / get_stats (viewer or above); update_app / set_resident_key / create_api_key / rename_credential / revoke_credential / revoke_user / issue_recovery_token (developer or above); delete_user (admin or above) |
Guide and pricing | — | get_integration_guide / list_plans |
Without project_id, create_app creates a project with two environments, Live and Test, as the dashboard does (the Test environment uses rp_id localhost and is returned as test_app_id). With project_id and environment (live / test), it adds the missing environment to that project. The plaintext API key is included only in the create_api_key response. Test keys start with sk_test_, and users in Test environments do not count toward billing. Every operation is recorded in the environment audit log with the person and client name. Passkey registration and login need the browser authenticator, so they are not tools; integrate them in code by following the guide.
Approved apps are listed under Account settings → Connected apps in the dashboard, where you can revoke them. Access tokens last 1 hour and refresh tokens 30 days, and both are replaced on each use. If a used refresh token is presented again, that connection is revoked.
OAuth endpoints
GET https://api.sealgate.jp/.well-known/oauth-protected-resource/v1/mcp Protected resource metadata (RFC 9728)
GET https://app.sealgate.jp/.well-known/oauth-authorization-server Authorization server metadata (RFC 8414)
POST https://app.sealgate.jp/api/oauth/register Dynamic client registration (RFC 7591, public clients)
GET https://app.sealgate.jp/api/oauth/authorize Authorization (code; PKCE S256 only; resource is the MCP URL)
POST https://app.sealgate.jp/api/oauth/token authorization_code / refresh_token
POST https://app.sealgate.jp/api/oauth/revoke Revocation (RFC 7009)
POST /v1/mcp without Authorization → 401
WWW-Authenticate: Bearer resource_metadata="https://api.sealgate.jp/.well-known/oauth-protected-resource/v1/mcp"Supported MCP-Protocol-Version values: 2025-11-25 / 2025-06-18 / 2025-03-26 / 2024-11-05.
Create your account in the dashboard (https://app.sealgate.jp/signup) with an email address, Google or GitHub. The MCP is used over a connection made by logging in (OAuth).
Browser SDK
Load https://sealgate.jp/sdk/v1.js (ESM), or use sealgate-browser from npm (same code, with types). optionsUrl and verifyUrl are URLs on your server. The SDK POSTs to them with kind (register | login).
npm install sealgate-browser
import { SealGate, SealGateError } from 'sealgate-browser';import { SealGate } from 'https://sealgate.jp/sdk/v1.js';
const sg = new SealGate({
optionsUrl: '/api/passkey/options',
verifyUrl: '/api/passkey/verify',
});
// Register (on a signed-in page; the same call adds more passkeys)
await sg.register({ name: 'This device' });
// Log in
const result = await sg.login(); // pick from passkeys on the device
const result2 = await sg.login({ external_id: 'user-123' });Autofill (conditional UI)
With mediation: 'conditional', passkeys appear in the username field's autofill instead of an authenticator dialog. The field needs autocomplete="username webauthn". Your server calls /v1/login/options without external_id. The Promise does not resolve until the user picks a passkey, so call it when the page loads.
<input name="username" autocomplete="username webauthn" />
if (await SealGate.conditionalMediationAvailable()) {
sg.login({}, { mediation: 'conditional' })
.then(onSignedIn)
.catch((e) => { if (e.code !== 'aborted') console.error(e); });
}
// When switching to button login, or leaving the page in a SPA
SealGate.abort();SealGate.supported() checks passkey support, SealGate.platformAuthenticatorAvailable() checks for a built-in biometric authenticator, and SealGate.conditionalMediationAvailable() checks autofill support. Failures throw SealGateError. code is one of cancelled, aborted (stopped by SealGate.abort() or by starting another operation), already_registered (this authenticator already has a passkey for the user), unsupported (not supported, or no autofill field), http_error (your server returned an error without error.code; status holds the HTTP status) or an API error code (error.code returned by your server, as is). cancelled covers the user closing the dialog or a timeout, and also any browser exception that fits none of the others (such as a SecurityError when rp_id does not match the page origin). The original description is in message, so log message during development. When the request to optionsUrl or verifyUrl itself fails (for example, the network is down), the fetch exception is thrown as is, not a SealGateError.
Server examples
Receive the POST from the SDK, forward it to SealGate, and issue a session when verify succeeds.
Node.js (Next.js Route Handler)
// app/api/passkey/[step]/route.ts — one handler for POST /api/passkey/options and /api/passkey/verify
const BASE = 'https://api.sealgate.jp/v1';
export async function POST(req, { params }) {
const { step } = await params; // 'options' | 'verify' (params is a Promise since Next.js 15)
const body = await req.json(); // { kind, ... } sent by the SDK
const user = await currentUser(req); // your app's signed-in user
const payload = step === 'options'
? body.kind === 'register'
// Adding a passkey needs signed_in: true. Decide it from your server session
? { external_id: user.id, display_name: user.email, signed_in: true }
: { external_id: body.external_id } // optional (omit for autofill)
: body.kind === 'register'
// Forward the name from sg.register({ name }) (the device name)
? { challenge_id: body.challenge_id, response: body.response, name: body.name }
: { challenge_id: body.challenge_id, response: body.response };
const res = await fetch(`${BASE}/${body.kind}/${step}`, {
method: 'POST',
headers: { authorization: `Bearer ${process.env.SEALGATE_API_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
const json = await res.json();
if (step === 'verify' && body.kind === 'login' && res.ok) {
await createSession(json.external_id); // your app's session
}
return Response.json(json, { status: res.status });
}curl
curl -X POST https://api.sealgate.jp/v1/login/options \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"external_id":"user-123"}'Server SDKs
SDKs for calling the API from your server. They cover starting and finishing registration and login, users and passkeys, recovery, the audit log and stats, and webhook signature verification, with waits and retries for 429 and an error type for each kind of error. Keep the API key on your server.
Node.js
npm install sealgate-serverimport { SealGate, verifyWebhook } from 'sealgate-server';
const sg = new SealGate(process.env.SEALGATE_API_KEY);
const start = await sg.login.options();
const r = await sg.login.verify({ challenge_id, response });
await createSession(r.external_id);
const event = await verifyWebhook(await req.text(), req.headers, process.env.SEALGATE_WEBHOOK_SECRET);Go
go get github.com/shannonllc/sealgate-goimport sealgate "github.com/shannonllc/sealgate-go"
sg, err := sealgate.NewClient(os.Getenv("SEALGATE_API_KEY"))
start, err := sg.Login.Options(ctx, nil)
r, err := sg.Login.Verify(ctx, &sealgate.LoginVerifyParams{ChallengeID: id, Response: resp})
createSession(r.ExternalID)
ev, err := sealgate.VerifyWebhook(body, req.Header, os.Getenv("SEALGATE_WEBHOOK_SECRET"))PHP
composer require shannonllc/sealgateuse SealGate\Client;
use function SealGate\verifyWebhook;
$sg = new Client(getenv('SEALGATE_API_KEY'));
$start = $sg->login->options();
$r = $sg->login->verify(['challenge_id' => $id, 'response' => $response]);
createSession($r['external_id']);
$event = verifyWebhook(file_get_contents('php://input'), $_SERVER, getenv('SEALGATE_WEBHOOK_SECRET'));Python
Coming soon
Errors
Errors have the shape { "error": { "code", "message" } }.
| code | HTTP | Meaning |
|---|---|---|
| invalid_request | 400 | Missing or malformed parameters |
| invalid_api_key | 401 | API key is missing or revoked |
| verification_failed | 401 | Signature verification failed, including when the passkey does not belong to the claimed user or rp_id does not match |
| credential_revoked | 403 | Login with a revoked passkey |
| origin_not_allowed | 403 | The origin of the registration or login response (origin in clientDataJSON) is not in the app's allowed origins. Check the allowed origins in the environment settings |
| recovery_not_allowed | 403 | /v1/register/options was called for a user who already has a passkey without signed_in or recovery_token, or the recovery_token is invalid, expired, already used or belongs to another user |
| mau_limit | 402 | The organization has no payment method and has reached the monthly limit of 1,000 MAU. Only users who have not logged in yet this month are blocked |
| not_found | 404 | User or passkey not found |
| challenge_expired | 410 | The challenge expired (5 minutes), was already used, or is unknown |
| rate_limited | 429 | Too many requests in a short time |
| internal_error | 500 | Error on the SealGate side |
| unavailable | 503 | Short maintenance. Retry after the Retry-After seconds |
Issue API keys in the dashboard. Contact us to start using SealGate.