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 no other personal data |
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 comes from the app setting (resident_key).
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 }],
"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) |
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 becomes false after the app's password coexistence end date.
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": "rcv_01j8x2k9m4n5p6q7r8s9t0v1w4",
"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",
"password_coexist_until": null,
"webhook_url": null
}PATCH/v1/apps/{app_id}
| Name | Type | Description |
|---|---|---|
resident_keyRequired | string | residentKey requested from the authenticator at registration. required / preferred / discouraged (default preferred) |
resident_key is the only setting you can change with an API key. Change rp_id, allowed origins and the webhook URL in the dashboard. Set it to required if you only use username-less login or autofill. You can also change it in the app 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) |
since | string | On or after this time (ISO 8601) |
limit | number | Number of entries (default 200, max 1000) |
Response (newest first):
{
"data": [
{
"id": "log_01j8x2k9m4n5p6q7r8s9t0v1w5",
"event": "login.succeeded",
"ok": true,
"end_user_id": "eu_01j8x2k9m4n5p6q7r8s9t0v1w2",
"credential_id": "crd_01j8x2k9m4n5p6q7r8s9t0v1w2",
"ip": "203.0.113.10",
"user_agent": "Mozilla/5.0 (iPhone; …)",
"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": [
{ "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": 214,
"logins": { "succeeded": 4210, "failed": 38 },
"users": { "total": 1830, "with_passkey": 1502 },
"transports": { "internal": 1420, "hybrid": 61, "usb": 21 }
}Webhooks
Set a webhook URL (https) in the environment settings in the dashboard, and SealGate POSTs these events as JSON: register.completed, register.failed, login.succeeded, login.failed, credential.revoked, user.revoked, user.deleted, recovery.issued. Failed deliveries are not retried.
POST <webhook_url>
X-SealGate-Event: login.succeeded
X-SealGate-Signature: sha256=<hex of HMAC-SHA256(body, signing secret)>
{ "id": "evt_...", "event": "login.succeeded", "app_id": "app_...", "at": "2026-09-22T00:00:00.000Z",
"data": { "end_user_id": "eu_...", "external_id": "user-123", "credential_id": "crd_..." } }The signing secret is shown in the dashboard when you set the URL. Compute HMAC-SHA256 over the raw body and compare.
import crypto from 'node:crypto';
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const ok = expected.length === sig.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));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. Dashboard login is limited to 30 attempts per IP and 10 attempts per email address every 15 minutes.
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.
With an API key (clients without OAuth support)
Call the same URL with an API key issued in the dashboard in Authorization: Bearer to use the tools of the key's environment (app), without app_id or organization_id. The available tools cover the environment's settings (except rp_id and allowed origins), the environment's API keys (list only), users and passkeys, recovery tokens, the audit log, stats and the organization's status (get_account). Creating apps, issuing and revoking API keys, changing allowed origins and billing are done over an MCP connection made by logging in (OAuth) or in the dashboard. Passing the key in a tool argument api_key is no longer supported, because the key would remain in the chat.
To sign up, use the signup tool at https://api.sealgate.jp/v1/mcp/signup (no authentication). It sends a confirmation link (valid for 24 hours) to the email address, and opening it creates the organization. No API key is returned. After confirming, issue an API key in the dashboard. get_integration_guide and list_plans are also available at this endpoint.
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 (the user closed the dialog or it timed out), aborted (stopped by SealGate.abort() or similar), already_registered (this authenticator already has a passkey for the user), unsupported (not supported, or no autofill field), http_error, or an API error code.
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)
// 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 = params.step; // 'options' | 'verify'
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)
: { 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"}'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 |
| credential_revoked | 403 | Login with a revoked passkey |
| origin_not_allowed | 403 | Response came from an origin not in the app's allowed origins |
| recovery_not_allowed | 403 | Adding a passkey for a user who already has one without signed_in or recovery_token; the recovery token is invalid or expired; or recovery was requested for an app with recovery disabled |
| 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 |
Issue API keys in the dashboard. Contact us to start using SealGate.