# SignOps API docs

Source: https://signops.dev/docs/ . OpenAPI: https://signops.dev/openapi.json

A REST API at `https://api.signops.dev`, with JSON in and out. Every product works under your own user ids, and every event reaches your server as a signed webhook.

## Quickstart

Five minutes from a new account to a push on your own phone. For each platform step by step, from the Firebase project or Apple key onward, see the [setup guides](https://signops.dev/guides/); to pick between products, [which product to use](https://signops.dev/guides/which-product/).

1.  **Get an API key.** [Sign up](https://app.signops.dev/signup); your live and test secret keys are shown once. Tell us what you are building and your domain (or, without one, apps like yours). Test keys work at once; live keys work once the account is approved from those answers, usually within a day. Keep secret keys on your server only.
2.  **Connect push.** In the dashboard under Push, Setup, upload your Firebase service account JSON, and your APNs `.p8` key if you send to iOS directly. Web push needs nothing.
3.  **Register a device.** Your app gets a token from the Firebase or Apple SDK and sends it to your server, which adds it under the user's id.
4.  **Send.** Call `/v1/push` with that user id.

```node
// Your server adds the token from FCM or APNs.
await signops.devices.add({
  user_id: "usr_8f21c4",   // your own user id
  token: fcmToken,
  platform: "android",          // android | ios | web
});

await signops.topics.subscribe("offers", {
  user_id: "usr_8f21c4",
});
```

```curl
curl https://api.signops.dev/v1/devices \
  -H "Authorization: Bearer $SIGNOPS_KEY" \
  -d user_id=usr_8f21c4 \
  -d token=$FCM_TOKEN \
  -d platform=android
```

```node
import { SignOps } from "@signops/node";

const signops = new SignOps(process.env.SIGNOPS_KEY);

// Every device of this user: Android, iOS, web.
await signops.push.send({
  user_id: "usr_8f21c4",
  title: "Your order is out for delivery",
  body: "Ravi is 10 minutes away with 6 items.",
  link: "kirana://orders/88213",
});
```

```php
use SignOps\SignOps;

$signops = new SignOps(getenv('SIGNOPS_KEY'));

$signops->push->send([
    'user_id' => 'usr_8f21c4',
    'title'   => 'Your order is out for delivery',
    'body'    => 'Ravi is 10 minutes away with 6 items.',
    'link'    => 'kirana://orders/88213',
]);
```

```python
from signops import SignOps

signops = SignOps(os.environ["SIGNOPS_KEY"])

signops.push.send(
    user_id="usr_8f21c4",
    title="Your order is out for delivery",
    body="Ravi is 10 minutes away with 6 items.",
    link="kirana://orders/88213",
)
```

```curl
curl https://api.signops.dev/v1/push \
  -H "Authorization: Bearer $SIGNOPS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "usr_8f21c4",
    "title": "Your order is out for delivery",
    "body": "Ravi is 10 minutes away with 6 items."
  }'
```

## API keys

Every request carries a secret key in the `Authorization` header. Each app has a live key, `sk_live_...`, and a test key, `sk_test_...`. Test keys send to devices and addresses you mark as test in the dashboard, and nothing they do is billed. On the Free plan they get half of each Free amount and 10 requests a second (see [Limits](https://signops.dev/docs/#limits)).

```
Authorization: Bearer sk_live_51Hk...
```

The SDKs inside your app use a publishable key, `pk_live_...`, which can only enrol devices and answer approvals for the signed-in user. Rotate either key from Settings; the old one keeps working for 24 hours.

The whole API is also an OpenAPI 3.1 file at [/openapi.json](https://signops.dev/openapi.json), for client generators and API tools, and these docs are plain Markdown at [/docs.md](https://signops.dev/docs.md) for coding agents ([/llms-full.txt](https://signops.dev/llms-full.txt) adds every guide).

Every request below is in the public [SignOps API workspace on Postman](https://www.postman.com/signops/signops-api): fork the SignOps environment, set `secret_key` to your test key and run them.

Send an `Idempotency-Key` header on any POST to make a retry safe. The same key within 24 hours returns the first result instead of sending twice.

## Errors

Errors return a status code and a JSON body with a `code` and a `message` written for a developer to read.

```
{ "error": { "code": "expired", "message": "Code otp_3kd92 expired at 10:12:51 IST." } }
```

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | invalid_request | A field is missing or has the wrong type. The message names it. |
| 401 | invalid_key | The API key is missing, wrong or revoked. |
| 401 | invalid_token | The inbox token is missing, changed, expired, or for another app. |
| 403 | not_allowed | The key is a test key used on live data, or the plan does not include it. |
| 403 | plan_required | The inbox, Live Activities, risk signals and verify are on Starter and Pro, for live and test keys. |
| 404 | no_step | POST /v1/verify: the user has none of the steps asked for. |
| 403 | domain_required | A live email code needs your own sending domain, verified. Test keys send to your test addresses meanwhile. |
| 403 | sending_paused | Email codes for this app are paused for 24 hours after too many bounces, spam complaints or unused codes. Retry-After says when. |
| 403 | sending_blocked | Email codes for this app are stopped until SignOps reviews it. |
| 404 | not_found | No such user, device, message or request on this app. |
| 409 | already_done | The code was already verified, or the request already answered. |
| 410 | expired | The code or request expired. |
| 422 | too_many_tries | Five wrong codes. Send a new one. |
| 422 | address_suppressed | The address bounced or marked a code as spam, so this app does not email it again. Remove it from the do-not-send list in the dashboard if it was a mistake. |
| 429 | user_locked | Five wrong authenticator codes in 15 minutes. The user is locked for the seconds in Retry-After, or until your server unlocks them. |
| 429 | rate_limited | Too many requests, pushes to one user past fair use, or codes past the per-address, per-IP or per-minute limit. Wait the seconds in Retry-After. |
| 429 | daily_cap | The app sent its email codes for the day at its sending level, or the organisation reached a Starter or Pro feature's daily cap. They start again at midnight IST; Retry-After says when. |
| 502 | not_sent | Amazon SES did not take the email. Retrying with the same Idempotency-Key is safe. |
| 500 | server_error | Our fault. Retrying with the same Idempotency-Key is safe. |

## Libraries and SDKs

**Server**

Node and TypeScript `@signops/node`, PHP `signops/signops-php`, Python `signops`. Anything else calls the REST API.

**App**

Android `com.signops:android`, iOS `SignOps` on Swift Package Manager, React Native `@signops/react-native`, Flutter `signops`.

**Web**

`@signops/web` for passkeys and web push registration, or one script tag.

**Login systems**

A Better Auth plugin and a Supabase helper (below), and guides for Firebase Auth, Auth0 and Amazon Cognito.

Runnable examples on GitHub: [signops-examples](https://github.com/signops-dev/signops-examples), with FCM HTTP v1 push from Node.js, passkeys in Next.js and Expo push with native tokens.

## Better Auth and Supabase

Your login system keeps issuing sessions; SignOps checks that the person is the user. The SignOps user id is the login system's own user id.

**Better Auth:** `@signops/better-auth` adds passkey sign-in and Approve sign-in as Better Auth endpoints that end in a Better Auth session, plus the tokens for adding a passkey and trusting a phone for the signed-in user.

```
import { signops } from "@signops/better-auth";
export const auth = betterAuth({ plugins: [signops({ secretKey: process.env.SIGNOPS_KEY })] });

// In the browser, after passkeys.signIn() from web.js:
await authClient.signops.passkey.signIn({ signopsToken: signops_token });
```

**Supabase:** `@signops/supabase` runs on your server with the service role key and turns a SignOps passkey or Approve sign-in into a Supabase session, made the way a Supabase magic link makes one but with no email sent. The page gives the session to `supabase.auth.setSession`.

```
import { SignOpsSupabase } from "@signops/supabase";
const so = new SignOpsSupabase({ admin, anon, secretKey: process.env.SIGNOPS_KEY });
const { session } = await so.passkeySession(signopsToken);
```

## MCP server

Coding agents such as Claude Code, Cursor and VS Code can work SignOps through its MCP server at `https://api.signops.dev/mcp`. Connect with a secret key in the `Authorization` header; the key decides the app and the mode. Use a test key (`sk_test_`) while building, so pushes reach only test devices. Nothing to install: it runs on the API, and every tool is one API call, logged and limited like any other.

**Claude Code**

```
claude mcp add --transport http signops https://api.signops.dev/mcp \
  --header "Authorization: Bearer sk_test_..."
```

**Cursor**, in `~/.cursor/mcp.json`

```
{ "mcpServers": { "signops": { "url": "https://api.signops.dev/mcp", "headers": { "Authorization": "Bearer sk_test_..." } } } }
```

**VS Code**, in `.vscode/mcp.json`

```
{ "servers": { "signops": { "type": "http", "url": "https://api.signops.dev/mcp", "headers": { "Authorization": "Bearer sk_test_..." } } } }
```

Other clients: streamable HTTP, POST only, with the same header, or the bare key in `X-API-Key` where a client keeps `Authorization` for itself. There is no OAuth sign-in; the key is the login. Without a key the server still answers `initialize` and `tools/list`, so a client can see the tools; calling one needs the key.

Configs for Cline and other clients, and an install file agents can follow: [signops-mcp on GitHub](https://github.com/signops-dev/signops-mcp).

- `send_push`: To a user, a list, a topic or everyone, now or later, from a template if you like.
- `get_push``list_pushes``cancel_push`: A push's result, recent pushes, cancel a scheduled one.
- `register_device``list_devices``remove_device`: A user's devices.
- `list_topics``subscribe_topic``unsubscribe_topic`: Topics and who is in them.
- `list_templates``get_template``save_template``delete_template`: Push templates.
- `create_approval``get_approval``cancel_approval`: Approve requests to a trusted phone.
- `list_passkeys`: A user's passkeys.
- `export_user``get_consent`: A user's data and consent. Erasing a user is left to your server and the dashboard.
- `get_authenticator``remove_authenticator``unlock_user`: A user's authenticator app and lock. Enrolment and code checks stay on your server, since they carry the key and the codes.

## Users and devices

A user is your own `user_id`, any string up to 128 characters. SignOps does not make one for you. A device is one token on one platform, `android`, `ios` or `web`, under a user. Adding the same token again updates it.

- POST `/v1/devices`: Add a device for a user
- GET `/v1/users/:user_id/devices`: List a user's devices
- DELETE `/v1/devices/:id`: Remove a device
- POST `/v1/devices/import`: Import up to 500 tokens a call

Tokens Google, Apple or a browser reject are removed, and a `device.removed` event tells your server. To move from OneSignal or Firebase, post `{ "devices": [{ user_id, platform, token, name }] }` to `/v1/devices/import`, up to 500 a call, or upload OneSignal's subscriber export (with external user ids) as a CSV under Push, Setup. Each row is checked on its own: the answer counts `imported` and `updated` and names each `skipped` row with its reason. Android tokens need your Firebase service account. iOS tokens from OneSignal are Apple device tokens, so they go straight to APNs with your `.p8` key. Browser subscriptions belong to the old provider's web push keys and cannot move; those users subscribe again on your site.

## Push

Setup guides: [Android](https://signops.dev/guides/android-push/), [iOS and the .p8 key](https://signops.dev/guides/ios-push/), [web](https://signops.dev/guides/web-push/), [React Native, Expo and Flutter](https://signops.dev/guides/react-native-flutter-push/). When a send fails: [FCM error codes](https://signops.dev/blog/fcm-error-codes/), [APNs error codes](https://signops.dev/blog/apns-error-codes/), [web push errors](https://signops.dev/blog/web-push-errors/), [Expo push errors](https://signops.dev/blog/expo-push-errors/). Test a key by hand: [APNs with curl](https://signops.dev/blog/apns-curl/).

Send to one of `user_id`, `user_ids` (up to 1,000), `topic` or `all: true`. Add `send_at` to schedule. The response returns at once with a message id; results arrive as events.

- POST `/v1/push`: Send now or at send_at
- GET `/v1/push/:id`: One message and its results
- DELETE `/v1/push/:id`: Cancel a scheduled message
- GET `/v1/push`: List messages, newest first
- POST `/v1/push/:id/opened`: SDK: the user opened the push or tapped a button

- `title`: The notification title.
- `body`: The text under it.
- `data`: Key and value pairs your app reads. With no title, the push is silent.
- `image`: An https image URL for rich notifications.
- `link`: A deep link or URL opened on tap.
- `buttons`: Up to three, each `{ id, title, link }`. See [Buttons and sounds](https://signops.dev/docs/#rich).
- `sound``badge`: A sound file in the app (or `false` for none), and the app icon's number.
- `android_channel``ios_category``thread_id`: The Android notification channel, the iOS category the app registered, and the iOS group.
- `priority``ttl`: `high` (the default) or `normal`, and the seconds a push may wait for an offline device, up to 28 days.
- `inbox`: `true` keeps it in each user's [inbox](https://signops.dev/docs/#inbox) too. Starter and Pro.
- `template`: A template's name, with `fields` to fill it. See [Templates](https://signops.dev/docs/#templates).
- `send_at`: An ISO time with a zone, up to 30 days ahead.
- `marketing`: `true` applies the app's daily cap per user.
- **Response**: To one `user_id`, now: the push is sent before the API answers, with `status` `sent`, `devices`, `sent` and `failed`. Otherwise `status` is `sending` or `scheduled`.

```node
// A topic, an image and a send time.
await signops.push.send({
  topic: "offers",
  title: "Weekend offer: 20% off fruit",
  body: "Mangoes, bananas and apples until Sunday night.",
  image: "https://cdn.kiranaexpress.in/offers/fruit.jpg",
  send_at: "2026-10-24T18:00:00+05:30",
});
```

```curl
curl https://api.signops.dev/v1/push \
  -H "Authorization: Bearer $SIGNOPS_KEY" \
  -d topic=offers \
  -d title="Weekend offer: 20% off fruit" \
  -d send_at=2026-10-24T18:00:00+05:30
```

## Buttons and sounds

A push can carry up to three buttons. Each has an `id`, a `title` and a `link` it opens. When the user taps one, the open is reported with the button's id as its `action` (on Starter and Pro; on Free it counts as a plain open).

-   **Web:** import `https://api.signops.dev/push-sw.js` in your service worker. It draws the buttons as notification actions (browsers show two), opens each button's link and reports the open.
-   **Android:** the system cannot draw buttons on an FCM notification, so a push with buttons goes as data only, and the SDK draws it: call `SignOps.handleMessage(this, message.data, R.drawable.ic_notification)` in your `FirebaseMessagingService` (in React Native and Flutter, `push.show(data)`).
-   **iOS:** buttons come from a notification category the app registered at launch (`Push.category("ORDER", buttons: [("track", "Track")])`). Send its identifier as `ios_category`; the button ids must match. The buttons and their links also travel in the push as `signops_buttons`.

```node
await signops.push.send({
  user_id: "usr_8f21c4",
  title: "Your order is out for delivery",
  body: "Ravi is 10 minutes away.",
  buttons: [
    { id: "track", title: "Track", link: "kirana://orders/88213" },
    { id: "call", title: "Call Ravi", link: "tel:+919800000000" },
  ],
  android_channel: "orders",
  ios_category: "ORDER",     // registered in the app with these buttons
  sound: "bell.caf",
  badge: 1,
  ttl: 900,                  // drop it if the phone is off for 15 minutes
});
```

```web
// sw.js at the root of your site: draws buttons, opens their links,
// and reports the open with the button as its action.
self.SIGNOPS_KEY = "pk_live_...";
importScripts("https://api.signops.dev/push-sw.js");
```

## Templates

Write a push once, in the dashboard under Push, Templates, or with the API, and send it by name. Put `{{field}}` where a value goes, or `{{field|default}}` for a value that falls back when it is not sent. Fields work in the title, body, image, link and data. A push that leaves out a field with no default is refused with `invalid_request` naming it, so no user sees `{{amount}}`.

- POST `/v1/templates`: Create or replace a template by name
- GET `/v1/templates`: List templates and their fields
- GET `/v1/templates/:name`: One template
- DELETE `/v1/templates/:name`: Delete a template

- `template`: The template's name, like `payment_received`.
- `fields`: Values for everyone the push reaches.
- `fields_by_user`: With `user_ids`: each user's own values, on top of `fields`.

```node
// Razorpay said payment.captured. The words live in the template:
// "Payment of Rs {{amount}} received" / "{{bill}}, receipt {{receipt_id}}."
await signops.push.send({
  user_id: "usr_8f21c4",
  template: "payment_received",
  fields: { amount: "499", bill: "MSEDCL electricity", receipt_id: "88" },
});
```

```python
signops.push.send(
    user_id="usr_8f21c4",
    template="payment_received",
    fields={"amount": "499", "bill": "MSEDCL electricity", "receipt_id": "88"},
)
```

```php
$signops->push->send([
    'user_id'  => 'usr_8f21c4',
    'template' => 'payment_received',
    'fields'   => ['amount' => '499', 'bill' => 'MSEDCL electricity', 'receipt_id' => '88'],
]);
```

```curl
curl https://api.signops.dev/v1/push \
  -H "Authorization: Bearer $SIGNOPS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "usr_8f21c4",
    "template": "payment_received",
    "fields": { "amount": "499", "bill": "MSEDCL electricity", "receipt_id": "88" }
  }'
```

A title or body sent with the push wins over the template's. Editing a template changes pushes sent afterwards; a push already scheduled keeps the words it was made with.

## Topics

A topic is a named list of users, made the first time you subscribe someone. Names are lowercase letters, digits and dashes, like `offers` or `city-pune`. A user can be in any number of topics.

- POST `/v1/topics/:name/subscribe`: Add a user to a topic
- POST `/v1/topics/:name/unsubscribe`: Take a user out of a topic
- GET `/v1/topics`: List topics and their sizes

## Approve

Setup guide: [Set up Approve](https://signops.dev/guides/approve/).

Approve needs the SignOps SDK in your app. After your own login, call `enroll` once; the SDK makes a key pair in the Android Keystore or iOS Secure Enclave and sends the public key. When someone signs in elsewhere, your server creates a request and shows the returned `match` number on that screen.

- POST `/v1/approve`: Create an approval request
- GET `/v1/approve/:id`: Read a request's status
- POST `/v1/approve/:id/cancel`: Cancel a pending request
- POST `/v1/approve/devices/enroll`: SDK: enrol a device's public key
- POST `/v1/approve/devices/push-token`: SDK: signed move to a new push token
- GET `/v1/approve/pending`: SDK: requests waiting for this device
- POST `/v1/approve/:id/respond`: SDK: signed approve or deny

A request is `pending` until the user answers or 2 minutes pass, then `approved`, `denied` or `expired`. Wait on it with `approve.wait`, which holds the connection up to 120 seconds, or take the `approve.*` webhook.

```server
// Someone is signing in on the web.
// Ask the user's trusted phone.
const challenge = await signops.approve.create({
  user_id: "usr_8f21c4",
  context: "Sign in from Chrome on Windows",
});

// Show challenge.match ("47") on the login page.
const result = await signops.approve.wait(challenge.id, {
  timeout: 120,
});

if (result.status === "approved") startSession(result.user_id);
```

```android
// Once, after your own login. Your server makes the token:
// signops.approve.enrollToken({ user_id }). Keys are made in the Keystore.
val signops = SignOps(context, "pk_live_...")
signops.approve.enroll(enrollToken, fcmToken)

// The approve push opens the ready-made screen.
Approve.requestId(message.data)?.let { signops.approve.open(this, it) }
```

```reactnative
import { ApproveScreen, SignOps } from "@signops/react-native";
const signops = new SignOps("pk_live_...");

// enrollToken comes from your server: signops.approve.enrollToken({ user_id })
await signops.approve.enroll(enrollToken, await messaging().getToken());

// A ready-made screen in your colours,
// or your own with signops.approve.pending().
<ApproveScreen signops={signops} requestId={id} colors={{ accent: "#12a463" }} />
```

## Passkeys

Setup guides: [on your website](https://signops.dev/guides/passkeys-web/), [in Android and iOS apps](https://signops.dev/guides/passkeys-apps/). Also: [passkey autofill](https://signops.dev/blog/passkey-autofill/), [WebAuthn errors](https://signops.dev/blog/webauthn-errors/).

Your domain is the relying party. Your server asks for a registration token for a user and passes it to the browser; `@signops/web` makes the passkey. On sign-in the script returns a short-lived `signops_token`, and your server confirms it with `/v1/passkeys/verify` before starting a session.

- POST `/v1/passkeys/registration-token`: Token for the browser to make a passkey
- POST `/v1/passkeys/verify`: Confirm a sign-in result on your server
- GET `/v1/users/:user_id/passkeys`: List a user's passkeys
- DELETE `/v1/passkeys/:id`: Remove a passkey

The same passkeys work in your Android and iOS apps through the SDK (Android 9 and later through Credential Manager, iOS 16 and later through AuthenticationServices). Add the app under Passkeys, Setup: the package name and the SHA-256 of its signing certificate for Android, the Team ID and bundle id for iOS. The dashboard then shows the `assetlinks.json` and `apple-app-site-association` files to host under `https://your-domain/.well-known/`; for iOS also add `webcredentials:your-domain` under Associated Domains. Your server makes the registration token and checks the `signops_token` exactly as for the web.

```browser
import { configure, passkeys } from "@signops/web";

configure({ key: "pk_live_..." });

// Register: the token comes from your server.
await passkeys.register({ token: registrationToken });

// Sign in, with autofill in the email field.
const { signops_token } = await passkeys.signIn({
  autofill: true,
});
// Send signops_token to your server to confirm.
```

```server
// A short-lived token for this user.
const { token } = await signops.passkeys.registrationToken({
  user_id: "usr_8f21c4",
  rp_id: "kiranaexpress.in",
});

// After sign-in, confirm it before starting a session.
const check = await signops.passkeys.verify(
  req.body.signops_token,
);
```

```android
// implementation("com.signops:android:0.2.0")
val signops = SignOps(context, "pk_live_...")

// Register: the token comes from your server.
signops.passkeys.register(activity, registrationToken)

// Sign in with Credential Manager.
val result = signops.passkeys.signIn(activity)
// Send result.signopsToken to your server to confirm.
```

```ios
// Swift Package Manager: SignOps
let signops = SignOps(key: "pk_live_...")

// Register: the token comes from your server.
try await signops.passkeys.register(token: registrationToken, anchor: window)

// Sign in, offered in the keyboard on your username field.
let result = try await signops.passkeys.signIn(anchor: window, autofill: true)
// Send result.signopsToken to your server to confirm.
```

## Authenticator app codes

Setup guide: [Set up authenticator app codes](https://signops.dev/guides/authenticator/), with a test on your own phone. If codes fail: [why correct codes get rejected](https://signops.dev/blog/totp-code-invalid/).

Codes from Google Authenticator or any TOTP app (RFC 6238: SHA-1, 6 digits, 30 seconds). Every call is from your server with the secret key. Enrol returns the key, an `otpauth://` link and a QR as SVG for your page; the user's first code confirms it and returns 10 backup codes, shown once. Verify takes either an app code or a backup code and answers `valid`, the `method` and `backup_codes_left`.

- POST `/v1/totp/enroll`: Start enrolment: key, otpauth link and QR
- POST `/v1/totp/confirm`: First code; returns 10 backup codes
- POST `/v1/totp/verify`: Check an app code or a backup code
- POST `/v1/totp/backup-codes`: New backup codes, the old ones stop
- GET `/v1/users/:user_id/totp`: Status, backup codes left, lock
- DELETE `/v1/users/:user_id/totp`: Remove, for a lost phone
- GET `/v1/users/:user_id/lock`: Whether wrong codes locked the user
- DELETE `/v1/users/:user_id/lock`: Unlock the user

```node
// Enrol: show the QR on your page.
const { qr_svg, secret } = await signops.totp.enroll({
  user_id: "usr_8f21c4",
  label: "ananya@example.in",
});

// The first code turns it on. Show the backup codes once.
const { backup_codes } = await signops.totp.confirm({
  user_id: "usr_8f21c4", code: req.body.code,
});

// At sign-in: a 6-digit code or a backup code.
const { valid, method } = await signops.totp.verify({
  user_id: "usr_8f21c4", code: req.body.code,
});
```

```php
$e = $signops->totp->enroll([
    'user_id' => 'usr_8f21c4',
    'label'   => 'ananya@example.in',
]);
// Show $e['qr_svg'] on your page.

$c = $signops->totp->confirm('usr_8f21c4', $request->input('code'));
// Show $c['backup_codes'] once.

$r = $signops->totp->verify('usr_8f21c4', $request->input('code'));
$ok = $r['valid'];
```

```python
e = signops.totp.enroll(
    user_id="usr_8f21c4",
    label="ananya@example.in",
)
# Show e.qr_svg on your page.

c = signops.totp.confirm("usr_8f21c4", request.form["code"])
# Show c.backup_codes once.

r = signops.totp.verify("usr_8f21c4", request.form["code"])
valid = r.valid
```

```curl
curl https://api.signops.dev/v1/totp/enroll \
  -H "Authorization: Bearer $SIGNOPS_KEY" \
  -d user_id=usr_8f21c4 -d label=ananya@example.in

curl https://api.signops.dev/v1/totp/confirm \
  -H "Authorization: Bearer $SIGNOPS_KEY" \
  -d user_id=usr_8f21c4 -d code=492039

curl https://api.signops.dev/v1/totp/verify \
  -H "Authorization: Bearer $SIGNOPS_KEY" \
  -d user_id=usr_8f21c4 -d code=815307
```

Each code works once. A wrong code answers `valid: false` with `failures_before_lock`; the fifth in 15 minutes locks the user, and verify answers 429 `user_locked` with Retry-After until the lock ends or your server calls `DELETE /v1/users/:user_id/lock`. A good code clears the count. The key and backup codes never appear in Logs, and `Idempotency-Key` is not kept for these calls, so a retry of enrol makes a new key.

## Email OTP

Setup guide: [Set up email OTP and magic links](https://signops.dev/guides/email-otp/), with the DNS records.

Send makes a 6-digit code, stores a keyed hash of it and emails it. Verify answers `valid: true` once, or `valid: false` with `tries_left`. Pass `language: "hi"` for Hindi and the user's `ip` for the per-IP limit. Live codes go from your own domain: add it in the dashboard under Email OTP, Domain, and put the five DNS records it shows at your DNS host. Test keys send from `codes@auth.signops.dev` to the test addresses you list there.

- POST `/v1/otp/send`: Make and email a code
- POST `/v1/otp/verify`: Check a code
- GET `/v1/otp/:id`: Status of a code: sent, verified, expired

```node
// Send: we make the code, hash it and email it from your domain.
const { otp_id } = await signops.otp.send({
  email: "ananya@example.in",
  app_name: "Kirana Express",
  ip: req.ip, // for the per-IP limit
});

// Check: true once, then the code is gone.
const { valid } = await signops.otp.verify({
  otp_id,
  code: req.body.code,
});
```

```php
$otp = $signops->otp->send([
    'email'    => 'ananya@example.in',
    'app_name' => 'Kirana Express',
]);

$result = $signops->otp->verify(
    $otp['otp_id'],
    $request->input('code'),
);
$ok = $result['valid'];
```

```python
otp = signops.otp.send(
    email="ananya@example.in",
    app_name="Kirana Express",
)

result = signops.otp.verify(
    otp_id=otp.otp_id,
    code=request.form["code"],
)
valid = result.valid
```

```curl
curl https://api.signops.dev/v1/otp/send \
  -H "Authorization: Bearer $SIGNOPS_KEY" \
  -d email=ananya@example.in -d app_name="Kirana Express" -d ip=203.0.113.7

curl https://api.signops.dev/v1/otp/verify \
  -H "Authorization: Bearer $SIGNOPS_KEY" \
  -d otp_id=otp_3kd92 -d code=482913
```

The email holds the code, your `app_name` (up to 40 characters, no web or email addresses) and your brand colour, and no links. SignOps sends sign-in and verification codes only; other email your users expect can be allowed for your domain after a review.

## Magic links

A sign-in link instead of a code. Send it with the address and `redirect_url`, the page on your site the link opens. SignOps adds `signops_token` to that URL; your page passes the token to your server, which checks it with verify. Verify answers `valid: true` with the email once, within 15 minutes. Links go through the same path as email codes: the same domain setup, limits, do-not-send list and price.

- POST `/v1/magic-links`: Email a sign-in link to a page on your site
- POST `/v1/magic-links/verify`: Check the token from the link, once
- GET `/v1/magic-links/:id`: Status of a link: sent, verified, expired

```node
// Send: a link to your page, from your domain.
await signops.magicLinks.send({
  email: "ananya@example.in",
  redirect_url: "https://kiranaexpress.in/auth/email",
  ip: req.ip,
});

// On /auth/email your page posts ?signops_token= here.
const { valid, email } = await signops.magicLinks.verify({
  token: req.body.signops_token,
});
```

```php
$signops->magicLinks->send([
    'email'        => 'ananya@example.in',
    'redirect_url' => 'https://kiranaexpress.in/auth/email',
]);

$result = $signops->magicLinks->verify($request->input('signops_token'));
$email = $result['valid'] ? $result['email'] : null;
```

```python
signops.magic_links.send(
    email="ananya@example.in",
    redirect_url="https://kiranaexpress.in/auth/email",
)

result = signops.magic_links.verify(token=request.form["signops_token"])
email = result.email if result.valid else None
```

```curl
curl https://api.signops.dev/v1/magic-links \
  -H "Authorization: Bearer $SIGNOPS_KEY" \
  -d email=ananya@example.in -d redirect_url=https://kiranaexpress.in/auth/email

curl https://api.signops.dev/v1/magic-links/verify \
  -H "Authorization: Bearer $SIGNOPS_KEY" \
  -d token=ml_7Qk2.Xb9fJ1
```

`redirect_url` must be https on your verified sending domain or one of its subdomains, or on a host you list under Email OTP, Settings, Magic links. With a test key it may also be `http://localhost`. Anything else is refused, so a link cannot send your users to another site. Some mail scanners open links before the user does: check the token when your page loads it with a button press or a POST from your page, not from a plain GET your server answers.

## Delete, export and consent (DPDP Act)

Under India's Digital Personal Data Protection Act, 2023 your users can ask what you hold on them and ask you to erase it. You are the data fiduciary and SignOps is your processor, so these calls are for your server: your user asks you, and you call SignOps. Free on every plan.

- DELETE `/v1/users/:user_id`: Erase everything held under the id
- GET `/v1/users/:user_id/export`: Everything held on the user, as JSON
- POST `/v1/users/:user_id/consent`: Record a yes or a no for a purpose
- GET `/v1/users/:user_id/consent`: Latest answer per purpose, and history

**Delete** removes, in one transaction, the user's devices and push tokens, topic memberships, passkeys, authenticator and backup codes, lock, approval requests, tokens, consent records and the user id. Pushes sent only to them (or to one of their devices) are deleted; list pushes lose the id and its fields. Request log lines, stored replies and events that name the user go too. The answer counts what went, and a `user.deleted` event, with only the id and the counts, is kept 30 days as the record of the erasure. The delete call itself is logged without the id. A user SignOps holds nothing on answers 404.

**Export** returns one JSON document: devices (the token shortened), topics, passkeys (never the public key), the authenticator (never the key or backup codes), approvals of the last 90 days, the consent history and the pushes they were sent.

**Consent** is a record, never a gate on its own: `{ "purpose": "marketing_push", "granted": true, "source": "signup_form", "text": "Send me offers" }`. Records are kept in order and the latest per purpose is the answer. `purpose` is your own word in lowercase letters, digits and underscores. Turn on **Marketing pushes need consent** in Settings and pushes marked `marketing: true` skip users whose latest `marketing_push` is not a yes; the push reports them as `skipped_consent`.

## Verify in one call

Starter and Pro. One session that tries a passkey, then Approve on a trusted phone, then the authenticator app, then an email code, in that fixed order, and says which one passed. Only the steps this user can do are kept: a user with no passkey starts at Approve. Leave steps out with `steps`; the order never changes. An Approve that is denied or expires, or an email code past its five tries, moves on by itself; `next` moves on when the user picks another way.

- POST `/v1/verify`: Start: the first step this user can do
- POST `/v1/verify/:id/check`: Passkey token, authenticator or email code
- GET `/v1/verify/:id`: Status; ?wait= follows an Approve step
- POST `/v1/verify/:id/next`: Move on to the next step

- `user_id``context`: Who, and what they are doing: Approve shows it on the phone.
- `email`: For the email step.
- `steps`: Some of `passkey`, `approve`, `totp`, `email`.
- `ip``user_agent``browser``city``country`: Shown by Approve, and used for [risk signals](https://signops.dev/docs/#signals).
- **Response**: `step` is the current one, with what it needs: `approve.match` to show on the page, `email.otp_id`, or `passkey.user_id` for the browser's `passkeys.signIn`. Passed: `status: "passed"`, `passed_by`, and `signals` when they are on.

```node
// Starter and Pro. Passkey, then Approve, then authenticator, then email:
// only the steps this user has, in that order.
const v = await signops.verify.start({
  user_id: "usr_8f21c4",
  context: "Sign in from Chrome on Windows",
  email: "ananya@example.in",
  user_agent: req.headers["user-agent"],
  ip: req.ip,
});
// v.step is "passkey", "approve", "totp" or "email".
// approve: show v.approve.match, then wait for the phone
const r = await signops.verify.get(v.id, { wait: 30 });
// passkey, totp, email: check what the user gave
const c = await signops.verify.check(v.id, { code: req.body.code });
if (c.status === "passed") signIn(c.user_id, c.signals);
// the user picks another way
await signops.verify.next(v.id);
```

```curl
curl https://api.signops.dev/v1/verify \
  -H "Authorization: Bearer $SIGNOPS_KEY" \
  -H "content-type: application/json" \
  -d '{"user_id":"usr_8f21c4","context":"Sign in","email":"ananya@example.in"}'

curl https://api.signops.dev/v1/verify/ver_.../check \
  -H "Authorization: Bearer $SIGNOPS_KEY" -d code=815307
```

Each step is billed as its product: an approval, a passkey sign-in, an authenticator check, an email code. The session itself has no price.

## Risk signals

Starter and Pro. Facts about a sign-in, never a score: each one says where it came from, and one SignOps cannot know is `null`. Turn them on under Settings, Sign-in safety, and passkey sign-ins carry them (from the browser's own connection, as Cloudflare sees it) in the answer to `/v1/passkeys/verify`, as do passed verify sessions. For your own sign-ins, call `/v1/signals` after the sign-in succeeds.

- POST `/v1/signals`: Record your own sign-in and get its signals

- `first_signin`: No sign-in was recorded for this user before.
- `new_device`: By the `device_id` you send, or else by browser and OS family (`device_basis` says which).
- `new_country`: Not among the user's last 10 countries. Null without a country.
- `impossible_travel`: Over 1,000 km/h across more than 500 km since the last sign-in, with `km`, `hours` and `kmh`. Null without coordinates both times.
- `tor`: The IP address is on the Tor Project's exit list, refreshed daily.
- `datacenter`: The network belongs to a cloud or hosting company, by its ASN. Only when Cloudflare saw the network: passkey sign-ins from the browser.

```node
// Starter and Pro. After your own sign-in succeeds:
const { signals, alert } = await signops.signals.record({
  user_id: "usr_8f21c4",
  ip: req.ip,
  user_agent: req.headers["user-agent"],
  country: req.headers["cf-ipcountry"],
  city: "Pune",
  email: user.email,        // for the alert, when the user has no device
});
if (signals.impossible_travel || signals.tor) askForASecondStep();
```

```json
{
  "first_signin": false,
  "new_device": true,
  "device_basis": "browser_and_os",
  "new_country": false,
  "country": "IN", "city": "Pune",
  "browser": "Chrome", "os": "Windows",
  "impossible_travel": false,
  "tor": false,
  "datacenter": null,
  "last_signin_at": "2026-10-24T09:41:04.000Z",
  "sources": { "place": "your_server", "new_device": "browser and OS from the user agent", "tor": "Tor Project exit list, refreshed daily" }
}
```

SignOps keeps one row per user: the last sign-in's time, country, city and coordinates, and the last 10 device keys and countries. Erasing the user removes it; it also goes after 180 days without a sign-in. Consumer VPNs are not detected.

## New-login alerts

Starter and Pro. With alerts on under Settings, Sign-in safety, a recorded sign-in from a new device or country (not the user's first) sends the user a notification: a push to their devices, or, when they have none, an email from your verified sending domain to the `email` you passed, counted as an email code. Write the title and text yourself with `{{app}}`, `{{browser}}`, `{{os}}`, `{{city}}`, `{{country}}` and `{{time}}`. The answer says how it went (`alert.sent` is `push`, `email` or null with a reason), and `signin.alerted` goes to your webhook.

## Notification inbox

Starter and Pro, Rs 0.003 a stored message. Add `inbox: true` to a push, or store one with `POST /v1/inbox`, and each user gets a row your app can list, mark read and remove. The app reads with the publishable key and an inbox token your server makes for the signed-in user, sent as the `SignOps-Inbox-Token` header; the token names the user, so one user cannot read another's inbox. Kept 30 days on Starter and 90 on Pro.

- POST `/v1/inbox/token`: Server: a token for the app, for one user
- POST `/v1/inbox`: Server: store a message without a push
- GET `/v1/users/:user_id/inbox`: Server: a user's inbox
- GET `/v1/inbox`: App: the inbox, with SignOps-Inbox-Token
- POST `/v1/inbox/read`: App: mark ids, or all, read
- DELETE `/v1/inbox/:id`: App: remove one

```node
// Starter and Pro. Keep a push in the user's inbox too:
await signops.push.send({
  user_id: "usr_8f21c4",
  title: "Your bill is ready",
  body: "Rs 499 for October",
  link: "https://kiranaexpress.in/bills/10",
  inbox: true,
});

// Your server, for the signed-in user: a token for the app.
const { token } = await signops.inbox.token("usr_8f21c4");
```

```web
import { configure, inbox } from "https://api.signops.dev/web.js";
configure({ key: "pk_live_..." });

// A ready-made list: unread marked, tap to read and open.
inbox.mount(document.getElementById("inbox"), {
  token,                        // from your server
  onUnread: (n) => badge(n),
});
```

The browser helper draws a ready-made list (`inbox.mount`); the Android, iOS, React Native and Flutter SDKs have `inbox.list`, `read`, `readAll` and `remove` for your own screen.

## Live Activities and ongoing notifications

Starter and Pro, Rs 0.002 an update (start and end count). For order and ride tracking: one notification that changes in place. Your server starts, updates and ends it by `ref`, your own id. On iOS the app starts the Live Activity with ActivityKit and the SDK sends its push token; updates then go straight to Apple, so the Apple key (`.p8`) must be uploaded under Push, iOS. On Android the SDK draws one ongoing notification per ref from the updates.

- POST `/v1/activities`: Start an activity for a user, by your ref
- POST `/v1/activities/:ref/update`: New state, progress or an alert
- POST `/v1/activities/:ref/end`: End it, with its last state
- GET `/v1/activities/:ref`: Status and the latest state
- POST `/v1/activities/:ref/token`: iOS SDK: the Live Activity's push token

- `title``body``progress`: What Android shows; 0 to 100 draws a progress bar.
- `content_state`: iOS: the object your `ContentState` decodes, with the same keys.
- `alert`: iOS: `{ title, body }` to light the screen for this update. Sent at Apple's high priority.
- `stale_at``dismiss_at`: iOS: when the activity looks out of date, and when an ended one leaves the lock screen.

```node
// Starter and Pro. One activity per ref, your own id.
await signops.activities.start({
  ref: "order_88213",
  user_id: "usr_8f21c4",
  title: "Preparing your order",
  progress: 10,
  content_state: { eta: 20 },   // iOS: your ContentState
});
await signops.activities.update("order_88213", {
  title: "Ravi is on the way",
  progress: 60,
  content_state: { eta: 8 },
});
await signops.activities.end("order_88213", { title: "Delivered" });
```

```swift
// iOS: start the Live Activity, then give SignOps its token.
let activity = try Activity.request(
  attributes: OrderAttributes(orderId: "88213"),
  content: .init(state: .init(eta: 20), staleDate: nil),
  pushType: .token)
signops.activities.register(ref: "order_88213", activity: activity)
```

```kotlin
// Android: in your FirebaseMessagingService.
override fun onMessageReceived(m: RemoteMessage) {
  // Draws activities and pushes with buttons. False for the rest.
  if (SignOps.handleMessage(this, m.data, R.drawable.ic_notification)) return
  // ...your own pushes
}
```

## Webhooks

Add an endpoint in Settings. Every event is posted as JSON with a `SignOps-Signature` header: an HMAC SHA-256 of the timestamp and the body, keyed with your endpoint's secret. Answer with any 2xx within 10 seconds. Failed deliveries are retried for 24 hours with backoff.

```node
// Every event is signed. Check it, then act on it.
const raw = express.raw({ type: "application/json" });

app.post("/signops", raw, (req, res) => {
  const event = signops.webhooks.verify(
    req.body,
    req.headers["signops-signature"],
  );

  if (event.type === "device.removed")
    db.tokens.delete(event.data.device_id);
  if (event.type === "approve.denied")
    alertSecurityTeam(event.data.user_id);

  res.sendStatus(200);
});
```

```json
{
  "id": "evt_9d2kq81",
  "type": "approve.approved",
  "created": "2026-10-24T09:41:04+05:30",
  "data": {
    "challenge_id": "apr_51mz0c",
    "user_id": "usr_8f21c4",
    "device": "Pixel 8",
    "matched": true,
    "biometric": "face"
  }
}
```

## Event types

| Event | When |
| --- | --- |
| push.sent | A message to a list, a topic or everyone was handed to FCM, APNs or the browser's push service. A push to one user answers with its result instead. |
| push.failed | A device rejected a message. Carries the reason. |
| push.opened | The user opened the push, or tapped a button (action). Needs the SDK in the app: every push carries its id as data.signops_id. |
| device.removed | A token was removed because Google, Apple or the browser rejected it. |
| approve.created | An approval request was sent to the user's trusted devices. |
| approve.approved | The user matched the number and confirmed with a biometric. |
| approve.denied | The user said it was not them. |
| approve.expired | Nobody answered in 2 minutes. Not billed. |
| passkey.created | A user made a new passkey. |
| passkey.used | A user signed in with a passkey. |
| totp.enrolled | A user confirmed an authenticator app with its first code. |
| totp.backup_code_used | A user signed in with a backup code. Carries how many are left. |
| totp.removed | A user's authenticator was removed, from your server or the dashboard. |
| user.locked | Five wrong codes in 15 minutes locked a user. Carries the method, locked_until and which lock this is. |
| user.deleted | A user was erased. Carries only the user id, the counts of what was removed and who asked. |
| consent.recorded | A consent answer was recorded. Carries the purpose, granted and source. |
| otp.sent | A code was emailed. |
| otp.verified | A code was checked and was right. |
| otp.failed | Five wrong tries, or the code expired. |
| magic_link.sent | A sign-in link was emailed. |
| magic_link.verified | A link's token was checked and was right. |
| magic_link.failed | Five wrong tokens for one link. |
| email.bounced | An address bounced and is now suppressed for your app. |
| email.complained | A user marked a code email as spam, and the address is now suppressed for your app. |
| email.warning | Your app's bounces, spam complaints or unused codes are past the warning line. Carries the topic and the numbers. |
| email.paused | Email codes for your app are paused for 24 hours, or stopped after a third pause in 30 days. Carries the reason and until. |
| usage.limit | A Free plan amount reached 80% or 100%. Carries the metric, level, used and limit. |
| signin.alerted | A new-login alert went to the user, by push or email. Carries new_device, new_country, country and city. |

## Limits

| What | Limit |
| --- | --- |
| API requests | 100 a second per app, bursts to 200. Test keys on Free: 10 a second. |
| Test keys on Free | Half of each Free amount, counted apart from live: 500 test devices, 50 approvals, 500 passkey sign-ins and 5,000 authenticator checks a month, 50 test codes a day. Then 403 (429 for codes) until the 1st, IST, or midnight for codes. Starter and Pro test keys are not limited this way. |
| Push pacing | Up to 5,000 pushes a second per app. |
| Push fair use | Lists, topics and everyone: an average of 10 pushes per device per day over a month. Pushes to one user: 3 per device per day across the app, at least 1,500 a day, up to a day's worth at once, then 429 with Retry-After. |
| User ids per push | 1,000. Use a topic for more. |
| Devices per user | 20 for push, 5 trusted for Approve, 10 passkeys. |
| Message size | 4 KB, the limit Google and Apple set. |
| send_at | Up to 30 days ahead. |
| Approval expiry | 2 minutes. |
| Code expiry and tries | 10 minutes, 5 tries, 30 seconds between resends. |
| Authenticator codes | One authenticator a user. A code is accepted 30 seconds early or late, and once. 5 wrong codes in 15 minutes lock the user for 1 minute, doubling to 1 hour. |
| Authenticator checks | 10,000 a month on Free, then 403 after 10% grace until the 1st, IST. 1 lakh on Starter and 5 lakh on Pro, then Rs 100 per 50,000. Every answered verify counts, right or wrong. |
| Passkey sign-ins | 1,000 a month on Free, then 403 until the 1st, IST. 50,000 on Starter and 2 lakh on Pro, then Rs 100 per 50,000. Registering a passkey is not counted. |
| Free refills | Rs 50 gives a Free organisation the month's limits again (devices, approvals, passkey sign-ins, authenticator checks), up to twice a month, from Settings, Billing. Email codes are not refilled. |
| Magic links | 15 minutes, once, 5 wrong tokens. They count with email codes toward every limit below and the daily cap, and are priced as codes. |
| Codes per address | 5 an hour, 30 seconds apart. Per IP address (when you pass ip), 20 an hour. Both can be changed per app. |
| Email codes a day | Level 1 (new): 1,000. Level 2, after 7 days and 1,000 clean codes: 10,000. Level 3, after 30 days and 25,000: 1 lakh. More on request. Up to 100 a minute. Test keys: 100 a day (50 on Free), to your test addresses only. |
| Email sending health | A warning at 2% bounces, 0.05% spam complaints, or fewer than 25% of a day's codes typed back; a 24-hour pause at 5%, 0.1% or under 10%; a stop after three pauses in 30 days. |
| Starter and Pro features a day | Per organisation, live and test together, to midnight IST. Starter: 3,000 inbox messages stored, 5,000 activity updates, 10,000 sign-ins with risk signals, 5,000 verify sessions. Pro: 10,000, 15,000, 30,000 and 15,000. Then 429 daily_cap; email us for more. |
| Inbox | Kept 30 days on Starter, 90 on Pro. A token lasts 24 hours by default, up to 7 days. Reads by the app are not logged. |
| Live Activities | One activity per ref. Apple ends a Live Activity after 8 hours; SignOps forgets it a day after it ends or 12 hours after its last update. Updates without an alert go at Apple's low priority, inside its budget. |
| Verify sessions | 10 minutes. Steps run passkey, Approve, authenticator, email; leave steps out with steps. |
| Logs | None on Free, 7 days on Starter, 30 on Pro. Approval records 7 days on Free, 90 on Starter, 1 year on Pro. A live push to one user from your server is answered with its result; it is kept in the logs (and found by GET /v1/push/:id, with open counts) only when it failed on some device, on Starter and Pro. Lists, topics, scheduled pushes, composer sends and test mode are always kept. |

`GET /v1/health` needs no key. It answers 200 with `ok: true` when the database answers, the scheduler ran in the last five minutes and no send is stuck, and 503 otherwise, so an uptime monitor can watch it.

## Android delivery in India

Xiaomi, Redmi, Poco, Oppo, Realme, Vivo and OnePlus phones stop apps in the background to save battery, which delays or drops pushes from any provider. SignOps sends high priority when the message is one the user expects, like an approval or a delivery update. In your app:

-   Ask for the notification permission at a moment the user understands, not at first launch.
-   For apps where pushes matter, like banking or delivery, show a one-time screen that opens the phone's battery settings for your app.
-   Use one notification channel for approvals and another for offers, so users can mute offers without missing logins.
-   Call `Approve.pending()` when the app opens, so a held approval still reaches the user.

Something missing? Email [hello@signops.dev](mailto:hello@signops.dev) and the answer goes into these docs.
