SignOps

Send push notifications to Android, iOS and the web, manage devices, topics and templates, and ask a trusted phone to approve a login, from your coding agent. Remote server at https://api.signops.dev/mcp, auth with a SignOps secret key.

Hosted MCP Server

npx add-mcp 'https://api.signops.dev/mcp'

Installs into Claude Code, Codex, Cursor and more

Documentation

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; to pick between products, which product to use.

  1. Get an API key. Sign up; 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.
// 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 https://api.signops.dev/v1/devices \
  -H "Authorization: Bearer $SIGNOPS_KEY" \
  -d user_id=usr_8f21c4 \
  -d token=$FCM_TOKEN \
  -d platform=android
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",
});
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',
]);
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 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).

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, for client generators and API tools, and these docs are plain Markdown at /docs.md for coding agents (/llms-full.txt adds every guide).

Every request below is in the public SignOps API workspace on Postman: 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." } }
StatusCodeMeaning
400invalid_requestA field is missing or has the wrong type. The message names it.
401invalid_keyThe API key is missing, wrong or revoked.
401invalid_tokenThe inbox token is missing, changed, expired, or for another app.
403not_allowedThe key is a test key used on live data, or the plan does not include it.
403plan_requiredThe inbox, Live Activities, risk signals and verify are on Starter and Pro, for live and test keys.
404no_stepPOST /v1/verify: the user has none of the steps asked for.
403domain_requiredA live email code needs your own sending domain, verified. Test keys send to your test addresses meanwhile.
403sending_pausedEmail codes for this app are paused for 24 hours after too many bounces, spam complaints or unused codes. Retry-After says when.
403sending_blockedEmail codes for this app are stopped until SignOps reviews it.
404not_foundNo such user, device, message or request on this app.
409already_doneThe code was already verified, or the request already answered.
410expiredThe code or request expired.
422too_many_triesFive wrong codes. Send a new one.
422address_suppressedThe 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.
429user_lockedFive wrong authenticator codes in 15 minutes. The user is locked for the seconds in Retry-After, or until your server unlocks them.
429rate_limitedToo 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.
429daily_capThe 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.
502not_sentAmazon SES did not take the email. Retrying with the same Idempotency-Key is safe.
500server_errorOur 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, 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.

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, iOS and the.p8 key, web, React Native, Expo and Flutter. When a send fails: FCM error codes, APNs error codes, web push errors, Expo push errors. Test a key by hand: APNs with 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.

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 too. Starter and Pro.

template A template's name, with fields to fill it. See 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.

// 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 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.
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
});
// 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.

// 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" },
});
signops.push.send(
    user_id="usr_8f21c4",
    template="payment_received",
    fields={"amount": "499", "bill": "MSEDCL electricity", "receipt_id": "88"},
)
$signops->push->send([
    'user_id'  => 'usr_8f21c4',
    'template' => 'payment_received',
    'fields'   => ['amount' => '499', 'bill' => 'MSEDCL electricity', 'receipt_id' => '88'],
]);
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.

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.

// 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);
// 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) }
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, in Android and iOS apps. Also: passkey autofill, 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.

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.
// 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,
);
// 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.
// 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, with a test on your own phone. If codes fail: why correct codes get rejected.

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

// 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,
});
$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'];
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 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, 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

// 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,
});
$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'];
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 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

// 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,
});
$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;
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 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.

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.

// 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 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.

// 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();
{
  "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

// 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");
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.

// 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" });
// 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)
// 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.

// 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);
});
{
  "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

EventWhen
push.sentA 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.failedA device rejected a message. Carries the reason.
push.openedThe 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.removedA token was removed because Google, Apple or the browser rejected it.
approve.createdAn approval request was sent to the user's trusted devices.
approve.approvedThe user matched the number and confirmed with a biometric.
approve.deniedThe user said it was not them.
approve.expiredNobody answered in 2 minutes. Not billed.
passkey.createdA user made a new passkey.
passkey.usedA user signed in with a passkey.
totp.enrolledA user confirmed an authenticator app with its first code.
totp.backup_code_usedA user signed in with a backup code. Carries how many are left.
totp.removedA user's authenticator was removed, from your server or the dashboard.
user.lockedFive wrong codes in 15 minutes locked a user. Carries the method, locked_until and which lock this is.
user.deletedA user was erased. Carries only the user id, the counts of what was removed and who asked.
consent.recordedA consent answer was recorded. Carries the purpose, granted and source.
otp.sentA code was emailed.
otp.verifiedA code was checked and was right.
otp.failedFive wrong tries, or the code expired.
magic_link.sentA sign-in link was emailed.
magic_link.verifiedA link's token was checked and was right.
magic_link.failedFive wrong tokens for one link.
email.bouncedAn address bounced and is now suppressed for your app.
email.complainedA user marked a code email as spam, and the address is now suppressed for your app.
email.warningYour app's bounces, spam complaints or unused codes are past the warning line. Carries the topic and the numbers.
email.pausedEmail codes for your app are paused for 24 hours, or stopped after a third pause in 30 days. Carries the reason and until.
usage.limitA Free plan amount reached 80% or 100%. Carries the metric, level, used and limit.
signin.alertedA new-login alert went to the user, by push or email. Carries new_device, new_country, country and city.

Limits

WhatLimit
API requests100 a second per app, bursts to 200. Test keys on Free: 10 a second.
Test keys on FreeHalf 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 pacingUp to 5,000 pushes a second per app.
Push fair useLists, 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 push1,000. Use a topic for more.
Devices per user20 for push, 5 trusted for Approve, 10 passkeys.
Message size4 KB, the limit Google and Apple set.
send_atUp to 30 days ahead.
Approval expiry2 minutes.
Code expiry and tries10 minutes, 5 tries, 30 seconds between resends.
Authenticator codesOne 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 checks10,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-ins1,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 refillsRs 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 links15 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 address5 an hour, 30 seconds apart. Per IP address (when you pass ip), 20 an hour. Both can be changed per app.
Email codes a dayLevel 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 healthA 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 dayPer 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.
InboxKept 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 ActivitiesOne 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 sessions10 minutes. Steps run passkey, Approve, authenticator, email; leave steps out with steps.
LogsNone 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 and the answer goes into these docs.