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.
- 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.
- Connect push. In the dashboard under Push, Setup, upload your Firebase service account JSON, and your APNs
.p8key if you send to iOS directly. Web push needs nothing. - 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.
- Send. Call
/v1/pushwith 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." } }
| 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, 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.jsin 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 yourFirebaseMessagingService(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 asios_category; the button ids must match. The buttons and their links also travel in the push assignops_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
| 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 and the answer goes into these docs.