# SignOps > Push notifications and login verification, on one API. One API key, one user id, one bill. SignOps is a developer API with five products that share the customer's own user ids: - Push: send push notifications to Android (FCM), iOS (FCM or APNs) and the web from a server. Users, topics, scheduling, templates, frequency caps, a composer for non-developers, delivery logs and signed webhooks. Customers upload their own Firebase and Apple keys. - Approve: approve a login or payment with a push to a phone the user already trusts. Number matching and a biometric on every request; the phone signs a fresh challenge with a key from the Android Keystore or iOS Secure Enclave. SDKs for Android, iOS, React Native and Flutter. - Passkeys: WebAuthn passkeys on the customer's own domain, on web, Android and iOS. SignOps runs the server side. - Authenticator: codes from Google Authenticator or any TOTP app (RFC 6238). QR enrolment, 10 backup codes, each code once, and a lock after 5 wrong codes in 15 minutes. The key is encrypted; backup codes are kept as HMACs. 10,000 checks a month on Free, 1 lakh on Starter and 5 lakh on Pro, then Rs 100 per 50,000. Every answered check counts, right or wrong. - Email OTP: send and verify 6-digit codes. Stored as a keyed hash, 10 minute expiry, 5 tries. Live codes go from the customer's own domain on every plan (DKIM through Amazon SES, US East); test keys send to test addresses. English and Hindi. Sign-in and verification codes only, no marketing email. Per-address, per-IP and daily limits, a do-not-send list, and warnings, 24-hour pauses and stops on bounces, spam complaints or unused codes. - Magic links: sign-in links by email (POST /v1/magic-links, then POST /v1/magic-links/verify with the token). The link opens the customer's own page on their verified domain or a listed host with a single-use token, valid 15 minutes. Same limits, do-not-send list and price as an email code. Push also carries buttons (up to 3, each with an id and a link; the tapped id is reported as the open's action), sound, badge, Android channel, iOS category and thread, priority and time to live. A ready-made service worker for web push is at https://api.signops.dev/push-sw.js. On Starter and Pro only, because each writes to the database per use, each with a daily cap per organisation: - POST /v1/verify: passkey, then Approve, then authenticator, then email code, in that fixed order, keeping only the steps the user can do. Each step is billed as its product. - Risk signals: facts about a sign-in, each with its source, never a score: first sign-in, new device, new country, impossible travel, Tor exit, datacenter network. On passkey sign-ins and verify when turned on, or POST /v1/signals for the customer's own sign-ins. - New-login alerts: a push, or an email from the customer's domain, when a sign-in comes from a new device or country. - Notification inbox: inbox: true on a push, or POST /v1/inbox; the app lists it with the publishable key and an inbox token from the server. Rs 0.003 a stored message, kept 30 days on Starter and 90 on Pro. - Live Activities (iOS, straight to Apple) and ongoing notifications (Android), started, updated and ended by the customer's own ref. Rs 0.002 an update. Login system plugins: @signops/better-auth (passkey and Approve sign-in as Better Auth sessions) and @signops/supabase (a SignOps sign-in turned into a Supabase session on the server). Status: https://status.signops.dev SignOps is made by the people behind Triplli (https://triplli.com/), quoting software for travel agencies, and Cabaro (https://cabarobytriplli.triplli.com/), booking software for cab operators. Coding agents can use SignOps through its MCP server at https://api.signops.dev/mcp (streamable HTTP, secret key in the Authorization header): send pushes, manage devices, topics and templates, create approvals, and check or remove a user's authenticator. See https://signops.dev/docs/#mcp, and client configs at https://github.com/signops-dev/signops-mcp. DPDP Act tools on every plan: DELETE /v1/users/:user_id erases everything held under a user id in one call (devices, passkeys, authenticator, approvals, consent, pushes and the log lines that name them), GET /v1/users/:user_id/export returns it as JSON, and POST /v1/users/:user_id/consent records consent; marketing pushes can be set to skip users without a yes. SignOps is not a login system. It works next to Supabase Auth, Better Auth, Firebase Auth, Auth0, Amazon Cognito or a custom one. ## Pricing (rupees, before 18% GST) Three plans. Use past a paid plan's included amounts is paid from a prepaid wallet the customer tops up (from Rs 500 before GST); it stops when the wallet is empty, and nothing is billed after the month. If a plan's autopay debit fails, the organisation moves to Free at once with email codes off until a plan is paid again. Payment questions and disputes: payments@signops.dev. - Free, Rs 0, a card only past the free amounts: one person, 1,000 push devices, 100 approvals and 1,000 passkey sign-ins a month, 10,000 authenticator checks a month, 500 email codes once to try, approval records 7 days, no delivery logs. A refill, Rs 50, gives the month's Free limits again except email codes, up to twice a month. - Starter, Rs 499 a month: 5,000 push devices (then Rs 0.20 a device, Rs 0.10 above 10 lakh), 100 approvals a day, 2,000 email codes and 1 lakh authenticator checks and 50,000 passkey sign-ins a month included, delivery logs 7 days, approval records 90 days, 3 apps, 50 team members. - Pro, Rs 1,499 a month: 10,000 push devices (then Rs 0.15 a device, Rs 0.10 above 10 lakh), 300 approvals a day, 10,000 email codes, 5 lakh authenticator checks and 2 lakh passkey sign-ins a month, delivery logs 30 days (longer by agreement), approval records 1 year, 10 apps, unlimited team. On Starter and Pro, approvals past the day's allowance are Rs 0.02 each, and email codes past the plan's 2,000 or 10,000 a month are Rs 0.025 each (Rs 0.02 above 1 lakh and Rs 0.015 above 10 lakh a month). Authenticator checks and passkey sign-ins past the plan are Rs 100 per 50,000 (Rs 0.002 each); on Free, checks pause after 10% grace and passkey sign-ins at 1,000, until the 1st. Expired approval requests are free. Inbox messages are Rs 0.003 each stored and Live Activity updates Rs 0.002 each, none included. A GST invoice in the customer's name comes with Starter and Pro. ## Pages - [Push](https://signops.dev/push/) - [Approve](https://signops.dev/approve/) - [Passkeys](https://signops.dev/passkeys/) - [Authenticator](https://signops.dev/authenticator/) - [Email OTP](https://signops.dev/email-otp/) - [Pricing](https://signops.dev/pricing/) - [Docs](https://signops.dev/docs/), also as Markdown: https://signops.dev/docs.md - [OpenAPI 3.1](https://signops.dev/openapi.json): every endpoint, request field and response. - [llms-full.txt](https://signops.dev/llms-full.txt): this file, the docs and every guide, comparison and post in one Markdown file. - [Examples on GitHub](https://github.com/signops-dev/signops-examples): FCM HTTP v1 push from Node.js (directly and through SignOps), passkeys in a Next.js App Router app, Expo push with native FCM and APNs tokens. - [Postman workspace](https://www.postman.com/signops/signops-api): every API request as a public Postman collection, with an environment for a test key. - [Compare](https://signops.dev/compare/) - [Security](https://signops.dev/security/) - [FAQ](https://signops.dev/faq/) ## Free tools - [Web push tester](https://signops.dev/tools/web-push-test/): send a real web push to this browser; shows the request, the push service's status code and the payload as received. No account. - [Passkey support checker](https://signops.dev/tools/passkey-check/): what the browser and device support for passkeys (platform authenticator, conditional UI, hybrid, sync, PRF). Runs in the browser, nothing sent. - [VAPID key generator and checker](https://signops.dev/tools/vapid-keys/): make a web push VAPID key pair, check a public and private key belong together, and check a vapid Authorization header (signature, aud, exp, sub, subscription key) for the causes of a 403. Runs in the browser, nothing sent. - [TOTP generator and code checker](https://signops.dev/tools/totp/): a TOTP secret, its otpauth URI and QR code, live codes, and which time step a pasted code matches. Runs in the browser, nothing sent. - [OTP cost calculator](https://signops.dev/tools/otp-cost-calculator/): monthly cost of OTP codes in India by SMS (MSG91, Twilio), WhatsApp (Meta's authentication rate) and email (Amazon SES, MSG91), from each provider's price page. ## Guides and comparisons - [Which product to use, and when](https://signops.dev/guides/which-product/): Push, Approve, passkeys, authenticator codes and email OTP matched to the jobs they do: sign-up, sign-in, two-step, payments, recovery and notifications. - [Set up push on Android](https://signops.dev/guides/android-push/): From a new Firebase project to a push on your Android phone: google-services.json, the service account key, the device token, and a first send. - [Set up push on iOS](https://signops.dev/guides/ios-push/): How to make an APNs .p8 key, find the Key ID, Team ID and bundle id, turn on push in Xcode, get the device token and send a first push. - [Set up web push](https://signops.dev/guides/web-push/): Web push with keys SignOps makes for you: a service worker, the permission prompt, the subscription on your server, and iPhone home screen apps. - [Set up push in React Native, Expo and Flutter](https://signops.dev/guides/react-native-flutter-push/): Use the Firebase messaging package your framework already has, register the token with SignOps, and send to Android and iOS from one call. - [Set up Approve](https://signops.dev/guides/approve/): Let users approve a sign-in or a payment on a phone they already use: enrol the phone after login, ask it from your server, and show the number to match. - [Set up passkeys on your website](https://signops.dev/guides/passkeys-web/): Your domain, the sign-in page origins, a registration token from your server, the browser prompt and the check before a session starts. - [Set up passkeys in your Android and iOS apps](https://signops.dev/guides/passkeys-apps/): Find the SHA-256 of your signing certificate, the Team ID and bundle id, host assetlinks.json and apple-app-site-association, and add Associated Domains. - [Set up authenticator app codes](https://signops.dev/guides/authenticator/): A two-step settings page with a QR code, backup codes shown once, the code check at sign-in, a lost phone, the lock, and how to test it on your phone. - [Set up email OTP and magic links](https://signops.dev/guides/email-otp/): Test with your own address first, then add your domain and its DNS records, send and check codes, and switch to sign-in links if you prefer. - [A OneSignal alternative, now that free mobile push stops at 1,000 users](https://signops.dev/onesignal-alternative/): OneSignal's free plan now stops mobile push at 1,000 monthly users. What it costs to stay, the free alternatives, and how SignOps compares in rupees. - [OTP service providers in India, compared on price and setup](https://signops.dev/otp-service-provider-india/): SMS, WhatsApp, email and push OTP providers in India with prices per code, DLT rules, and the options that need no DLT registration. - [An MSG91 alternative for apps that want more than SMS](https://signops.dev/msg91-alternative/): What MSG91 charges for SMS and email OTP, where it is the right choice, and when SignOps push, approvals and passkeys cost less. - [Twilio Verify pricing in India, and an alternative](https://signops.dev/twilio-verify-alternative/): Twilio Verify is $0.05 a verification (about Rs 4.75), plus about Rs 7.90 an SMS to India. What 1 lakh sign-ins cost, and SignOps in rupees. - [Free push notification services for Android, iOS and the web](https://signops.dev/free-push-notification-service/): Every free push notification option in 2026, what the free tier covers, and what you still have to build: FCM, Expo, OneSignal, Pusher, Truepush and SignOps. - [Migrating from the FCM legacy API to HTTP v1](https://signops.dev/blog/fcm-http-v1-migration/): The FCM legacy API is shut down. How to move to HTTP v1: service account tokens, the new payload, sending to many tokens, and Node, PHP and cURL examples. - [FCM says success but the notification never arrives](https://signops.dev/blog/fcm-notification-not-received/): Why Android does not show FCM notifications when the app is closed or killed: message types, priority, Doze, permissions, stale tokens and OEM battery savers. - [FCM error codes, and what to do about each](https://signops.dev/blog/fcm-error-codes/): Every FCM HTTP v1 error code: UNREGISTERED, INVALID_ARGUMENT, SENDER_ID_MISMATCH, QUOTA_EXCEEDED and more, with the Admin SDK codes and when to retry. - [APNs error codes, and what to do about each](https://signops.dev/blog/apns-error-codes/): Every APNs error reason Apple documents, from BadDeviceToken and DeviceTokenNotForTopic to 410 Unregistered and the provider token errors, with fixes. - [Why push notifications go missing on Xiaomi, Oppo, Vivo and Realme](https://signops.dev/blog/push-notifications-xiaomi-oppo-vivo/): Battery savers on Xiaomi, Redmi, Poco, Oppo, Realme, Vivo and OnePlus stop apps and drop pushes. What developers can change in the app and what users must switch on. - [Is Firebase push notification free or paid?](https://signops.dev/blog/firebase-push-notification-pricing/): Yes. FCM is free on every Firebase plan, with no message limit. The quotas: 600,000 messages a minute, 240 a minute to one phone. What costs money around it. - [Sending push notifications from a Node.js server](https://signops.dev/blog/send-push-notification-node-js/): Send FCM push notifications from Node.js: firebase-admin setup, one user, many tokens, topics, all users, scheduling and cleaning up dead tokens. - [Expo push notifications work in development but not in the APK](https://signops.dev/blog/expo-push-notifications-not-working/): The common reasons Expo push notifications stop in production builds: FCM v1 credentials, google-services.json, Expo Go limits, receipts and Android channels. - [What an SMS OTP costs in India, and what DLT asks of you](https://signops.dev/blog/sms-otp-cost-india/): SMS OTP charges in India per message, what 1,000 and 1 lakh OTPs cost, what DLT registration involves, and the options that need no DLT. - [Supabase "email rate limit exceeded": the fix](https://signops.dev/blog/supabase-email-rate-limit/): Supabase's built-in email sender is for testing and allows only a few emails an hour. How custom SMTP raises the limit, and other ways to send sign-in codes. - [Email OTP in Better Auth, end to end](https://signops.dev/blog/better-auth-email-otp/): Set up the Better Auth email OTP plugin, choose a sender for the codes, and handle expiry, tries and rate limits in production. - [Firebase Auth has no email OTP. Here is how to add one](https://signops.dev/blog/firebase-auth-email-otp/): Firebase Auth offers email links, not 6-digit email codes. How to build email OTP sign-in with a Cloud Function and a custom token. - [Email OTP or SMS OTP?](https://signops.dev/blog/email-otp-vs-sms-otp/): Email OTP and SMS OTP compared on price in India, DLT, delivery, SIM swap and phishing risk, with when to use each. - [Passkeys or OTP?](https://signops.dev/blog/passkeys-vs-otp/): How passkeys work, why they cannot be phished like an OTP, which devices support them, and how to add them next to the login you already have. - [Push authentication, MFA fatigue and number matching](https://signops.dev/blog/push-authentication-number-matching/): What push authentication is, how MFA fatigue attacks beat it, why number matching stops them, and how it compares with an OTP. - [The RBI's 2025 authentication directions, for developers](https://signops.dev/blog/rbi-authentication-directions-2025/): What the RBI's Authentication Mechanisms for Digital Payment Transactions Directions, 2025 ask of apps from 1 April 2026, and the alternatives to SMS OTP they name. - [WebAuthn and passkey errors, and what each one means](https://signops.dev/blog/webauthn-errors/): Every error navigator.credentials.create() and get() throw: NotAllowedError, InvalidStateError, SecurityError for rp.id, ConstraintError, with causes and fixes. - [Passkey autofill: show passkeys in the username field](https://signops.dev/blog/passkey-autofill/): Set up passkey autofill with mediation conditional: the autocomplete attribute, the feature check, an empty allowCredentials, aborting for the button, and fixes. - [Web push errors, and what to do about each](https://signops.dev/blog/web-push-errors/): Every status a web push service returns, from 201 to 410 Gone, 403 for VAPID key problems, 413 payload too large and 429, with what to delete, fix or retry. - [Web push received but not shown](https://signops.dev/blog/web-push-not-showing/): Why a web push arrives but no notification shows, or Chrome shows its generic notice: waitUntil, userVisibleOnly, tag and renotify, and OS settings. - [Web push on iPhone and iPad](https://signops.dev/blog/web-push-ios/): What iOS needs for web push: version 16.4, a Home Screen web app, a standalone manifest and a prompt from a tap. With detection code and a service worker. - [The Android 13 notification permission, POST_NOTIFICATIONS](https://signops.dev/blog/android-notification-permission/): Notifications are off by default on Android 13 and later. How to declare and ask for POST_NOTIFICATIONS in context, handle two denials, and test it with adb. - [Send a test push to an iPhone with curl](https://signops.dev/blog/apns-curl/): Make an APNs JWT from your .p8 key with openssl and send an alert or a background push with curl over HTTP/2, with every header and the errors you may see. - [Expo push notification errors, and what to do about each](https://signops.dev/blog/expo-push-errors/): Every Expo push ticket, receipt and request error: DeviceNotRegistered, MessageTooBig, MismatchSenderId, InvalidCredentials, TOO_MANY_REQUESTS, with fixes. - [Why your server rejects correct authenticator codes](https://signops.dev/blog/totp-code-invalid/): Authenticator app codes rejected by your server: clock drift, base32 secrets, leading zeros, ignored parameters and reused codes, with a TOTP check in Node. ## SignOps API docs Source: https://signops.dev/docs/ . OpenAPI: https://signops.dev/openapi.json A REST API at `https://api.signops.dev`, with JSON in and out. Every product works under your own user ids, and every event reaches your server as a signed webhook. ### Quickstart Five minutes from a new account to a push on your own phone. For each platform step by step, from the Firebase project or Apple key onward, see the [setup guides](https://signops.dev/guides/); to pick between products, [which product to use](https://signops.dev/guides/which-product/). 1. **Get an API key.** [Sign up](https://app.signops.dev/signup); your live and test secret keys are shown once. Tell us what you are building and your domain (or, without one, apps like yours). Test keys work at once; live keys work once the account is approved from those answers, usually within a day. Keep secret keys on your server only. 2. **Connect push.** In the dashboard under Push, Setup, upload your Firebase service account JSON, and your APNs `.p8` key if you send to iOS directly. Web push needs nothing. 3. **Register a device.** Your app gets a token from the Firebase or Apple SDK and sends it to your server, which adds it under the user's id. 4. **Send.** Call `/v1/push` with that user id. ```node // Your server adds the token from FCM or APNs. await signops.devices.add({ user_id: "usr_8f21c4", // your own user id token: fcmToken, platform: "android", // android | ios | web }); await signops.topics.subscribe("offers", { user_id: "usr_8f21c4", }); ``` ```curl curl https://api.signops.dev/v1/devices \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -d user_id=usr_8f21c4 \ -d token=$FCM_TOKEN \ -d platform=android ``` ```node import { SignOps } from "@signops/node"; const signops = new SignOps(process.env.SIGNOPS_KEY); // Every device of this user: Android, iOS, web. await signops.push.send({ user_id: "usr_8f21c4", title: "Your order is out for delivery", body: "Ravi is 10 minutes away with 6 items.", link: "kirana://orders/88213", }); ``` ```php use SignOps\SignOps; $signops = new SignOps(getenv('SIGNOPS_KEY')); $signops->push->send([ 'user_id' => 'usr_8f21c4', 'title' => 'Your order is out for delivery', 'body' => 'Ravi is 10 minutes away with 6 items.', 'link' => 'kirana://orders/88213', ]); ``` ```python from signops import SignOps signops = SignOps(os.environ["SIGNOPS_KEY"]) signops.push.send( user_id="usr_8f21c4", title="Your order is out for delivery", body="Ravi is 10 minutes away with 6 items.", link="kirana://orders/88213", ) ``` ```curl curl https://api.signops.dev/v1/push \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -H "Content-Type: application/json" \ -d '{ "user_id": "usr_8f21c4", "title": "Your order is out for delivery", "body": "Ravi is 10 minutes away with 6 items." }' ``` ### API keys Every request carries a secret key in the `Authorization` header. Each app has a live key, `sk_live_...`, and a test key, `sk_test_...`. Test keys send to devices and addresses you mark as test in the dashboard, and nothing they do is billed. On the Free plan they get half of each Free amount and 10 requests a second (see [Limits](https://signops.dev/docs/#limits)). ``` Authorization: Bearer sk_live_51Hk... ``` The SDKs inside your app use a publishable key, `pk_live_...`, which can only enrol devices and answer approvals for the signed-in user. Rotate either key from Settings; the old one keeps working for 24 hours. The whole API is also an OpenAPI 3.1 file at [/openapi.json](https://signops.dev/openapi.json), for client generators and API tools, and these docs are plain Markdown at [/docs.md](https://signops.dev/docs.md) for coding agents ([/llms-full.txt](https://signops.dev/llms-full.txt) adds every guide). Every request below is in the public [SignOps API workspace on Postman](https://www.postman.com/signops/signops-api): fork the SignOps environment, set `secret_key` to your test key and run them. Send an `Idempotency-Key` header on any POST to make a retry safe. The same key within 24 hours returns the first result instead of sending twice. ### Errors Errors return a status code and a JSON body with a `code` and a `message` written for a developer to read. ``` { "error": { "code": "expired", "message": "Code otp_3kd92 expired at 10:12:51 IST." } } ``` | Status | Code | Meaning | | --- | --- | --- | | 400 | invalid_request | A field is missing or has the wrong type. The message names it. | | 401 | invalid_key | The API key is missing, wrong or revoked. | | 401 | invalid_token | The inbox token is missing, changed, expired, or for another app. | | 403 | not_allowed | The key is a test key used on live data, or the plan does not include it. | | 403 | plan_required | The inbox, Live Activities, risk signals and verify are on Starter and Pro, for live and test keys. | | 404 | no_step | POST /v1/verify: the user has none of the steps asked for. | | 403 | domain_required | A live email code needs your own sending domain, verified. Test keys send to your test addresses meanwhile. | | 403 | sending_paused | Email codes for this app are paused for 24 hours after too many bounces, spam complaints or unused codes. Retry-After says when. | | 403 | sending_blocked | Email codes for this app are stopped until SignOps reviews it. | | 404 | not_found | No such user, device, message or request on this app. | | 409 | already_done | The code was already verified, or the request already answered. | | 410 | expired | The code or request expired. | | 422 | too_many_tries | Five wrong codes. Send a new one. | | 422 | address_suppressed | The address bounced or marked a code as spam, so this app does not email it again. Remove it from the do-not-send list in the dashboard if it was a mistake. | | 429 | user_locked | Five wrong authenticator codes in 15 minutes. The user is locked for the seconds in Retry-After, or until your server unlocks them. | | 429 | rate_limited | Too many requests, pushes to one user past fair use, or codes past the per-address, per-IP or per-minute limit. Wait the seconds in Retry-After. | | 429 | daily_cap | The app sent its email codes for the day at its sending level, or the organisation reached a Starter or Pro feature's daily cap. They start again at midnight IST; Retry-After says when. | | 502 | not_sent | Amazon SES did not take the email. Retrying with the same Idempotency-Key is safe. | | 500 | server_error | Our fault. Retrying with the same Idempotency-Key is safe. | ### Libraries and SDKs **Server** Node and TypeScript `@signops/node`, PHP `signops/signops-php`, Python `signops`. Anything else calls the REST API. **App** Android `com.signops:android`, iOS `SignOps` on Swift Package Manager, React Native `@signops/react-native`, Flutter `signops`. **Web** `@signops/web` for passkeys and web push registration, or one script tag. **Login systems** A Better Auth plugin and a Supabase helper (below), and guides for Firebase Auth, Auth0 and Amazon Cognito. Runnable examples on GitHub: [signops-examples](https://github.com/signops-dev/signops-examples), with FCM HTTP v1 push from Node.js, passkeys in Next.js and Expo push with native tokens. ### Better Auth and Supabase Your login system keeps issuing sessions; SignOps checks that the person is the user. The SignOps user id is the login system's own user id. **Better Auth:** `@signops/better-auth` adds passkey sign-in and Approve sign-in as Better Auth endpoints that end in a Better Auth session, plus the tokens for adding a passkey and trusting a phone for the signed-in user. ``` import { signops } from "@signops/better-auth"; export const auth = betterAuth({ plugins: [signops({ secretKey: process.env.SIGNOPS_KEY })] }); // In the browser, after passkeys.signIn() from web.js: await authClient.signops.passkey.signIn({ signopsToken: signops_token }); ``` **Supabase:** `@signops/supabase` runs on your server with the service role key and turns a SignOps passkey or Approve sign-in into a Supabase session, made the way a Supabase magic link makes one but with no email sent. The page gives the session to `supabase.auth.setSession`. ``` import { SignOpsSupabase } from "@signops/supabase"; const so = new SignOpsSupabase({ admin, anon, secretKey: process.env.SIGNOPS_KEY }); const { session } = await so.passkeySession(signopsToken); ``` ### MCP server Coding agents such as Claude Code, Cursor and VS Code can work SignOps through its MCP server at `https://api.signops.dev/mcp`. Connect with a secret key in the `Authorization` header; the key decides the app and the mode. Use a test key (`sk_test_`) while building, so pushes reach only test devices. Nothing to install: it runs on the API, and every tool is one API call, logged and limited like any other. **Claude Code** ``` claude mcp add --transport http signops https://api.signops.dev/mcp \ --header "Authorization: Bearer sk_test_..." ``` **Cursor**, in `~/.cursor/mcp.json` ``` { "mcpServers": { "signops": { "url": "https://api.signops.dev/mcp", "headers": { "Authorization": "Bearer sk_test_..." } } } } ``` **VS Code**, in `.vscode/mcp.json` ``` { "servers": { "signops": { "type": "http", "url": "https://api.signops.dev/mcp", "headers": { "Authorization": "Bearer sk_test_..." } } } } ``` Other clients: streamable HTTP, POST only, with the same header, or the bare key in `X-API-Key` where a client keeps `Authorization` for itself. There is no OAuth sign-in; the key is the login. Without a key the server still answers `initialize` and `tools/list`, so a client can see the tools; calling one needs the key. Configs for Cline and other clients, and an install file agents can follow: [signops-mcp on GitHub](https://github.com/signops-dev/signops-mcp). - `send_push`: To a user, a list, a topic or everyone, now or later, from a template if you like. - `get_push``list_pushes``cancel_push`: A push's result, recent pushes, cancel a scheduled one. - `register_device``list_devices``remove_device`: A user's devices. - `list_topics``subscribe_topic``unsubscribe_topic`: Topics and who is in them. - `list_templates``get_template``save_template``delete_template`: Push templates. - `create_approval``get_approval``cancel_approval`: Approve requests to a trusted phone. - `list_passkeys`: A user's passkeys. - `export_user``get_consent`: A user's data and consent. Erasing a user is left to your server and the dashboard. - `get_authenticator``remove_authenticator``unlock_user`: A user's authenticator app and lock. Enrolment and code checks stay on your server, since they carry the key and the codes. ### Users and devices A user is your own `user_id`, any string up to 128 characters. SignOps does not make one for you. A device is one token on one platform, `android`, `ios` or `web`, under a user. Adding the same token again updates it. - POST `/v1/devices`: Add a device for a user - GET `/v1/users/:user_id/devices`: List a user's devices - DELETE `/v1/devices/:id`: Remove a device - POST `/v1/devices/import`: Import up to 500 tokens a call Tokens Google, Apple or a browser reject are removed, and a `device.removed` event tells your server. To move from OneSignal or Firebase, post `{ "devices": [{ user_id, platform, token, name }] }` to `/v1/devices/import`, up to 500 a call, or upload OneSignal's subscriber export (with external user ids) as a CSV under Push, Setup. Each row is checked on its own: the answer counts `imported` and `updated` and names each `skipped` row with its reason. Android tokens need your Firebase service account. iOS tokens from OneSignal are Apple device tokens, so they go straight to APNs with your `.p8` key. Browser subscriptions belong to the old provider's web push keys and cannot move; those users subscribe again on your site. ### Push Setup guides: [Android](https://signops.dev/guides/android-push/), [iOS and the .p8 key](https://signops.dev/guides/ios-push/), [web](https://signops.dev/guides/web-push/), [React Native, Expo and Flutter](https://signops.dev/guides/react-native-flutter-push/). When a send fails: [FCM error codes](https://signops.dev/blog/fcm-error-codes/), [APNs error codes](https://signops.dev/blog/apns-error-codes/), [web push errors](https://signops.dev/blog/web-push-errors/), [Expo push errors](https://signops.dev/blog/expo-push-errors/). Test a key by hand: [APNs with curl](https://signops.dev/blog/apns-curl/). Send to one of `user_id`, `user_ids` (up to 1,000), `topic` or `all: true`. Add `send_at` to schedule. The response returns at once with a message id; results arrive as events. - POST `/v1/push`: Send now or at send_at - GET `/v1/push/:id`: One message and its results - DELETE `/v1/push/:id`: Cancel a scheduled message - GET `/v1/push`: List messages, newest first - POST `/v1/push/:id/opened`: SDK: the user opened the push or tapped a button - `title`: The notification title. - `body`: The text under it. - `data`: Key and value pairs your app reads. With no title, the push is silent. - `image`: An https image URL for rich notifications. - `link`: A deep link or URL opened on tap. - `buttons`: Up to three, each `{ id, title, link }`. See [Buttons and sounds](https://signops.dev/docs/#rich). - `sound``badge`: A sound file in the app (or `false` for none), and the app icon's number. - `android_channel``ios_category``thread_id`: The Android notification channel, the iOS category the app registered, and the iOS group. - `priority``ttl`: `high` (the default) or `normal`, and the seconds a push may wait for an offline device, up to 28 days. - `inbox`: `true` keeps it in each user's [inbox](https://signops.dev/docs/#inbox) too. Starter and Pro. - `template`: A template's name, with `fields` to fill it. See [Templates](https://signops.dev/docs/#templates). - `send_at`: An ISO time with a zone, up to 30 days ahead. - `marketing`: `true` applies the app's daily cap per user. - **Response**: To one `user_id`, now: the push is sent before the API answers, with `status` `sent`, `devices`, `sent` and `failed`. Otherwise `status` is `sending` or `scheduled`. ```node // A topic, an image and a send time. await signops.push.send({ topic: "offers", title: "Weekend offer: 20% off fruit", body: "Mangoes, bananas and apples until Sunday night.", image: "https://cdn.kiranaexpress.in/offers/fruit.jpg", send_at: "2026-10-24T18:00:00+05:30", }); ``` ```curl curl https://api.signops.dev/v1/push \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -d topic=offers \ -d title="Weekend offer: 20% off fruit" \ -d send_at=2026-10-24T18:00:00+05:30 ``` ### Buttons and sounds A push can carry up to three buttons. Each has an `id`, a `title` and a `link` it opens. When the user taps one, the open is reported with the button's id as its `action` (on Starter and Pro; on Free it counts as a plain open). - **Web:** import `https://api.signops.dev/push-sw.js` in your service worker. It draws the buttons as notification actions (browsers show two), opens each button's link and reports the open. - **Android:** the system cannot draw buttons on an FCM notification, so a push with buttons goes as data only, and the SDK draws it: call `SignOps.handleMessage(this, message.data, R.drawable.ic_notification)` in your `FirebaseMessagingService` (in React Native and Flutter, `push.show(data)`). - **iOS:** buttons come from a notification category the app registered at launch (`Push.category("ORDER", buttons: [("track", "Track")])`). Send its identifier as `ios_category`; the button ids must match. The buttons and their links also travel in the push as `signops_buttons`. ```node await signops.push.send({ user_id: "usr_8f21c4", title: "Your order is out for delivery", body: "Ravi is 10 minutes away.", buttons: [ { id: "track", title: "Track", link: "kirana://orders/88213" }, { id: "call", title: "Call Ravi", link: "tel:+919800000000" }, ], android_channel: "orders", ios_category: "ORDER", // registered in the app with these buttons sound: "bell.caf", badge: 1, ttl: 900, // drop it if the phone is off for 15 minutes }); ``` ```web // sw.js at the root of your site: draws buttons, opens their links, // and reports the open with the button as its action. self.SIGNOPS_KEY = "pk_live_..."; importScripts("https://api.signops.dev/push-sw.js"); ``` ### Templates Write a push once, in the dashboard under Push, Templates, or with the API, and send it by name. Put `{{field}}` where a value goes, or `{{field|default}}` for a value that falls back when it is not sent. Fields work in the title, body, image, link and data. A push that leaves out a field with no default is refused with `invalid_request` naming it, so no user sees `{{amount}}`. - POST `/v1/templates`: Create or replace a template by name - GET `/v1/templates`: List templates and their fields - GET `/v1/templates/:name`: One template - DELETE `/v1/templates/:name`: Delete a template - `template`: The template's name, like `payment_received`. - `fields`: Values for everyone the push reaches. - `fields_by_user`: With `user_ids`: each user's own values, on top of `fields`. ```node // Razorpay said payment.captured. The words live in the template: // "Payment of Rs {{amount}} received" / "{{bill}}, receipt {{receipt_id}}." await signops.push.send({ user_id: "usr_8f21c4", template: "payment_received", fields: { amount: "499", bill: "MSEDCL electricity", receipt_id: "88" }, }); ``` ```python signops.push.send( user_id="usr_8f21c4", template="payment_received", fields={"amount": "499", "bill": "MSEDCL electricity", "receipt_id": "88"}, ) ``` ```php $signops->push->send([ 'user_id' => 'usr_8f21c4', 'template' => 'payment_received', 'fields' => ['amount' => '499', 'bill' => 'MSEDCL electricity', 'receipt_id' => '88'], ]); ``` ```curl curl https://api.signops.dev/v1/push \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -H "Content-Type: application/json" \ -d '{ "user_id": "usr_8f21c4", "template": "payment_received", "fields": { "amount": "499", "bill": "MSEDCL electricity", "receipt_id": "88" } }' ``` A title or body sent with the push wins over the template's. Editing a template changes pushes sent afterwards; a push already scheduled keeps the words it was made with. ### Topics A topic is a named list of users, made the first time you subscribe someone. Names are lowercase letters, digits and dashes, like `offers` or `city-pune`. A user can be in any number of topics. - POST `/v1/topics/:name/subscribe`: Add a user to a topic - POST `/v1/topics/:name/unsubscribe`: Take a user out of a topic - GET `/v1/topics`: List topics and their sizes ### Approve Setup guide: [Set up Approve](https://signops.dev/guides/approve/). Approve needs the SignOps SDK in your app. After your own login, call `enroll` once; the SDK makes a key pair in the Android Keystore or iOS Secure Enclave and sends the public key. When someone signs in elsewhere, your server creates a request and shows the returned `match` number on that screen. - POST `/v1/approve`: Create an approval request - GET `/v1/approve/:id`: Read a request's status - POST `/v1/approve/:id/cancel`: Cancel a pending request - POST `/v1/approve/devices/enroll`: SDK: enrol a device's public key - POST `/v1/approve/devices/push-token`: SDK: signed move to a new push token - GET `/v1/approve/pending`: SDK: requests waiting for this device - POST `/v1/approve/:id/respond`: SDK: signed approve or deny A request is `pending` until the user answers or 2 minutes pass, then `approved`, `denied` or `expired`. Wait on it with `approve.wait`, which holds the connection up to 120 seconds, or take the `approve.*` webhook. ```server // Someone is signing in on the web. // Ask the user's trusted phone. const challenge = await signops.approve.create({ user_id: "usr_8f21c4", context: "Sign in from Chrome on Windows", }); // Show challenge.match ("47") on the login page. const result = await signops.approve.wait(challenge.id, { timeout: 120, }); if (result.status === "approved") startSession(result.user_id); ``` ```android // Once, after your own login. Your server makes the token: // signops.approve.enrollToken({ user_id }). Keys are made in the Keystore. val signops = SignOps(context, "pk_live_...") signops.approve.enroll(enrollToken, fcmToken) // The approve push opens the ready-made screen. Approve.requestId(message.data)?.let { signops.approve.open(this, it) } ``` ```reactnative import { ApproveScreen, SignOps } from "@signops/react-native"; const signops = new SignOps("pk_live_..."); // enrollToken comes from your server: signops.approve.enrollToken({ user_id }) await signops.approve.enroll(enrollToken, await messaging().getToken()); // A ready-made screen in your colours, // or your own with signops.approve.pending(). ``` ### Passkeys Setup guides: [on your website](https://signops.dev/guides/passkeys-web/), [in Android and iOS apps](https://signops.dev/guides/passkeys-apps/). Also: [passkey autofill](https://signops.dev/blog/passkey-autofill/), [WebAuthn errors](https://signops.dev/blog/webauthn-errors/). Your domain is the relying party. Your server asks for a registration token for a user and passes it to the browser; `@signops/web` makes the passkey. On sign-in the script returns a short-lived `signops_token`, and your server confirms it with `/v1/passkeys/verify` before starting a session. - POST `/v1/passkeys/registration-token`: Token for the browser to make a passkey - POST `/v1/passkeys/verify`: Confirm a sign-in result on your server - GET `/v1/users/:user_id/passkeys`: List a user's passkeys - DELETE `/v1/passkeys/:id`: Remove a passkey The same passkeys work in your Android and iOS apps through the SDK (Android 9 and later through Credential Manager, iOS 16 and later through AuthenticationServices). Add the app under Passkeys, Setup: the package name and the SHA-256 of its signing certificate for Android, the Team ID and bundle id for iOS. The dashboard then shows the `assetlinks.json` and `apple-app-site-association` files to host under `https://your-domain/.well-known/`; for iOS also add `webcredentials:your-domain` under Associated Domains. Your server makes the registration token and checks the `signops_token` exactly as for the web. ```browser import { configure, passkeys } from "@signops/web"; configure({ key: "pk_live_..." }); // Register: the token comes from your server. await passkeys.register({ token: registrationToken }); // Sign in, with autofill in the email field. const { signops_token } = await passkeys.signIn({ autofill: true, }); // Send signops_token to your server to confirm. ``` ```server // A short-lived token for this user. const { token } = await signops.passkeys.registrationToken({ user_id: "usr_8f21c4", rp_id: "kiranaexpress.in", }); // After sign-in, confirm it before starting a session. const check = await signops.passkeys.verify( req.body.signops_token, ); ``` ```android // implementation("com.signops:android:0.2.0") val signops = SignOps(context, "pk_live_...") // Register: the token comes from your server. signops.passkeys.register(activity, registrationToken) // Sign in with Credential Manager. val result = signops.passkeys.signIn(activity) // Send result.signopsToken to your server to confirm. ``` ```ios // Swift Package Manager: SignOps let signops = SignOps(key: "pk_live_...") // Register: the token comes from your server. try await signops.passkeys.register(token: registrationToken, anchor: window) // Sign in, offered in the keyboard on your username field. let result = try await signops.passkeys.signIn(anchor: window, autofill: true) // Send result.signopsToken to your server to confirm. ``` ### Authenticator app codes Setup guide: [Set up authenticator app codes](https://signops.dev/guides/authenticator/), with a test on your own phone. If codes fail: [why correct codes get rejected](https://signops.dev/blog/totp-code-invalid/). Codes from Google Authenticator or any TOTP app (RFC 6238: SHA-1, 6 digits, 30 seconds). Every call is from your server with the secret key. Enrol returns the key, an `otpauth://` link and a QR as SVG for your page; the user's first code confirms it and returns 10 backup codes, shown once. Verify takes either an app code or a backup code and answers `valid`, the `method` and `backup_codes_left`. - POST `/v1/totp/enroll`: Start enrolment: key, otpauth link and QR - POST `/v1/totp/confirm`: First code; returns 10 backup codes - POST `/v1/totp/verify`: Check an app code or a backup code - POST `/v1/totp/backup-codes`: New backup codes, the old ones stop - GET `/v1/users/:user_id/totp`: Status, backup codes left, lock - DELETE `/v1/users/:user_id/totp`: Remove, for a lost phone - GET `/v1/users/:user_id/lock`: Whether wrong codes locked the user - DELETE `/v1/users/:user_id/lock`: Unlock the user ```node // Enrol: show the QR on your page. const { qr_svg, secret } = await signops.totp.enroll({ user_id: "usr_8f21c4", label: "ananya@example.in", }); // The first code turns it on. Show the backup codes once. const { backup_codes } = await signops.totp.confirm({ user_id: "usr_8f21c4", code: req.body.code, }); // At sign-in: a 6-digit code or a backup code. const { valid, method } = await signops.totp.verify({ user_id: "usr_8f21c4", code: req.body.code, }); ``` ```php $e = $signops->totp->enroll([ 'user_id' => 'usr_8f21c4', 'label' => 'ananya@example.in', ]); // Show $e['qr_svg'] on your page. $c = $signops->totp->confirm('usr_8f21c4', $request->input('code')); // Show $c['backup_codes'] once. $r = $signops->totp->verify('usr_8f21c4', $request->input('code')); $ok = $r['valid']; ``` ```python e = signops.totp.enroll( user_id="usr_8f21c4", label="ananya@example.in", ) ## Show e.qr_svg on your page. c = signops.totp.confirm("usr_8f21c4", request.form["code"]) ## Show c.backup_codes once. r = signops.totp.verify("usr_8f21c4", request.form["code"]) valid = r.valid ``` ```curl curl https://api.signops.dev/v1/totp/enroll \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -d user_id=usr_8f21c4 -d label=ananya@example.in curl https://api.signops.dev/v1/totp/confirm \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -d user_id=usr_8f21c4 -d code=492039 curl https://api.signops.dev/v1/totp/verify \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -d user_id=usr_8f21c4 -d code=815307 ``` Each code works once. A wrong code answers `valid: false` with `failures_before_lock`; the fifth in 15 minutes locks the user, and verify answers 429 `user_locked` with Retry-After until the lock ends or your server calls `DELETE /v1/users/:user_id/lock`. A good code clears the count. The key and backup codes never appear in Logs, and `Idempotency-Key` is not kept for these calls, so a retry of enrol makes a new key. ### Email OTP Setup guide: [Set up email OTP and magic links](https://signops.dev/guides/email-otp/), with the DNS records. Send makes a 6-digit code, stores a keyed hash of it and emails it. Verify answers `valid: true` once, or `valid: false` with `tries_left`. Pass `language: "hi"` for Hindi and the user's `ip` for the per-IP limit. Live codes go from your own domain: add it in the dashboard under Email OTP, Domain, and put the five DNS records it shows at your DNS host. Test keys send from `codes@auth.signops.dev` to the test addresses you list there. - POST `/v1/otp/send`: Make and email a code - POST `/v1/otp/verify`: Check a code - GET `/v1/otp/:id`: Status of a code: sent, verified, expired ```node // Send: we make the code, hash it and email it from your domain. const { otp_id } = await signops.otp.send({ email: "ananya@example.in", app_name: "Kirana Express", ip: req.ip, // for the per-IP limit }); // Check: true once, then the code is gone. const { valid } = await signops.otp.verify({ otp_id, code: req.body.code, }); ``` ```php $otp = $signops->otp->send([ 'email' => 'ananya@example.in', 'app_name' => 'Kirana Express', ]); $result = $signops->otp->verify( $otp['otp_id'], $request->input('code'), ); $ok = $result['valid']; ``` ```python otp = signops.otp.send( email="ananya@example.in", app_name="Kirana Express", ) result = signops.otp.verify( otp_id=otp.otp_id, code=request.form["code"], ) valid = result.valid ``` ```curl curl https://api.signops.dev/v1/otp/send \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -d email=ananya@example.in -d app_name="Kirana Express" -d ip=203.0.113.7 curl https://api.signops.dev/v1/otp/verify \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -d otp_id=otp_3kd92 -d code=482913 ``` The email holds the code, your `app_name` (up to 40 characters, no web or email addresses) and your brand colour, and no links. SignOps sends sign-in and verification codes only; other email your users expect can be allowed for your domain after a review. ### Magic links A sign-in link instead of a code. Send it with the address and `redirect_url`, the page on your site the link opens. SignOps adds `signops_token` to that URL; your page passes the token to your server, which checks it with verify. Verify answers `valid: true` with the email once, within 15 minutes. Links go through the same path as email codes: the same domain setup, limits, do-not-send list and price. - POST `/v1/magic-links`: Email a sign-in link to a page on your site - POST `/v1/magic-links/verify`: Check the token from the link, once - GET `/v1/magic-links/:id`: Status of a link: sent, verified, expired ```node // Send: a link to your page, from your domain. await signops.magicLinks.send({ email: "ananya@example.in", redirect_url: "https://kiranaexpress.in/auth/email", ip: req.ip, }); // On /auth/email your page posts ?signops_token= here. const { valid, email } = await signops.magicLinks.verify({ token: req.body.signops_token, }); ``` ```php $signops->magicLinks->send([ 'email' => 'ananya@example.in', 'redirect_url' => 'https://kiranaexpress.in/auth/email', ]); $result = $signops->magicLinks->verify($request->input('signops_token')); $email = $result['valid'] ? $result['email'] : null; ``` ```python signops.magic_links.send( email="ananya@example.in", redirect_url="https://kiranaexpress.in/auth/email", ) result = signops.magic_links.verify(token=request.form["signops_token"]) email = result.email if result.valid else None ``` ```curl curl https://api.signops.dev/v1/magic-links \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -d email=ananya@example.in -d redirect_url=https://kiranaexpress.in/auth/email curl https://api.signops.dev/v1/magic-links/verify \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -d token=ml_7Qk2.Xb9fJ1 ``` `redirect_url` must be https on your verified sending domain or one of its subdomains, or on a host you list under Email OTP, Settings, Magic links. With a test key it may also be `http://localhost`. Anything else is refused, so a link cannot send your users to another site. Some mail scanners open links before the user does: check the token when your page loads it with a button press or a POST from your page, not from a plain GET your server answers. ### Delete, export and consent (DPDP Act) Under India's Digital Personal Data Protection Act, 2023 your users can ask what you hold on them and ask you to erase it. You are the data fiduciary and SignOps is your processor, so these calls are for your server: your user asks you, and you call SignOps. Free on every plan. - DELETE `/v1/users/:user_id`: Erase everything held under the id - GET `/v1/users/:user_id/export`: Everything held on the user, as JSON - POST `/v1/users/:user_id/consent`: Record a yes or a no for a purpose - GET `/v1/users/:user_id/consent`: Latest answer per purpose, and history **Delete** removes, in one transaction, the user's devices and push tokens, topic memberships, passkeys, authenticator and backup codes, lock, approval requests, tokens, consent records and the user id. Pushes sent only to them (or to one of their devices) are deleted; list pushes lose the id and its fields. Request log lines, stored replies and events that name the user go too. The answer counts what went, and a `user.deleted` event, with only the id and the counts, is kept 30 days as the record of the erasure. The delete call itself is logged without the id. A user SignOps holds nothing on answers 404. **Export** returns one JSON document: devices (the token shortened), topics, passkeys (never the public key), the authenticator (never the key or backup codes), approvals of the last 90 days, the consent history and the pushes they were sent. **Consent** is a record, never a gate on its own: `{ "purpose": "marketing_push", "granted": true, "source": "signup_form", "text": "Send me offers" }`. Records are kept in order and the latest per purpose is the answer. `purpose` is your own word in lowercase letters, digits and underscores. Turn on **Marketing pushes need consent** in Settings and pushes marked `marketing: true` skip users whose latest `marketing_push` is not a yes; the push reports them as `skipped_consent`. ### Verify in one call Starter and Pro. One session that tries a passkey, then Approve on a trusted phone, then the authenticator app, then an email code, in that fixed order, and says which one passed. Only the steps this user can do are kept: a user with no passkey starts at Approve. Leave steps out with `steps`; the order never changes. An Approve that is denied or expires, or an email code past its five tries, moves on by itself; `next` moves on when the user picks another way. - POST `/v1/verify`: Start: the first step this user can do - POST `/v1/verify/:id/check`: Passkey token, authenticator or email code - GET `/v1/verify/:id`: Status; ?wait= follows an Approve step - POST `/v1/verify/:id/next`: Move on to the next step - `user_id``context`: Who, and what they are doing: Approve shows it on the phone. - `email`: For the email step. - `steps`: Some of `passkey`, `approve`, `totp`, `email`. - `ip``user_agent``browser``city``country`: Shown by Approve, and used for [risk signals](https://signops.dev/docs/#signals). - **Response**: `step` is the current one, with what it needs: `approve.match` to show on the page, `email.otp_id`, or `passkey.user_id` for the browser's `passkeys.signIn`. Passed: `status: "passed"`, `passed_by`, and `signals` when they are on. ```node // Starter and Pro. Passkey, then Approve, then authenticator, then email: // only the steps this user has, in that order. const v = await signops.verify.start({ user_id: "usr_8f21c4", context: "Sign in from Chrome on Windows", email: "ananya@example.in", user_agent: req.headers["user-agent"], ip: req.ip, }); // v.step is "passkey", "approve", "totp" or "email". // approve: show v.approve.match, then wait for the phone const r = await signops.verify.get(v.id, { wait: 30 }); // passkey, totp, email: check what the user gave const c = await signops.verify.check(v.id, { code: req.body.code }); if (c.status === "passed") signIn(c.user_id, c.signals); // the user picks another way await signops.verify.next(v.id); ``` ```curl curl https://api.signops.dev/v1/verify \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -H "content-type: application/json" \ -d '{"user_id":"usr_8f21c4","context":"Sign in","email":"ananya@example.in"}' curl https://api.signops.dev/v1/verify/ver_.../check \ -H "Authorization: Bearer $SIGNOPS_KEY" -d code=815307 ``` Each step is billed as its product: an approval, a passkey sign-in, an authenticator check, an email code. The session itself has no price. ### Risk signals Starter and Pro. Facts about a sign-in, never a score: each one says where it came from, and one SignOps cannot know is `null`. Turn them on under Settings, Sign-in safety, and passkey sign-ins carry them (from the browser's own connection, as Cloudflare sees it) in the answer to `/v1/passkeys/verify`, as do passed verify sessions. For your own sign-ins, call `/v1/signals` after the sign-in succeeds. - POST `/v1/signals`: Record your own sign-in and get its signals - `first_signin`: No sign-in was recorded for this user before. - `new_device`: By the `device_id` you send, or else by browser and OS family (`device_basis` says which). - `new_country`: Not among the user's last 10 countries. Null without a country. - `impossible_travel`: Over 1,000 km/h across more than 500 km since the last sign-in, with `km`, `hours` and `kmh`. Null without coordinates both times. - `tor`: The IP address is on the Tor Project's exit list, refreshed daily. - `datacenter`: The network belongs to a cloud or hosting company, by its ASN. Only when Cloudflare saw the network: passkey sign-ins from the browser. ```node // Starter and Pro. After your own sign-in succeeds: const { signals, alert } = await signops.signals.record({ user_id: "usr_8f21c4", ip: req.ip, user_agent: req.headers["user-agent"], country: req.headers["cf-ipcountry"], city: "Pune", email: user.email, // for the alert, when the user has no device }); if (signals.impossible_travel || signals.tor) askForASecondStep(); ``` ```json { "first_signin": false, "new_device": true, "device_basis": "browser_and_os", "new_country": false, "country": "IN", "city": "Pune", "browser": "Chrome", "os": "Windows", "impossible_travel": false, "tor": false, "datacenter": null, "last_signin_at": "2026-10-24T09:41:04.000Z", "sources": { "place": "your_server", "new_device": "browser and OS from the user agent", "tor": "Tor Project exit list, refreshed daily" } } ``` SignOps keeps one row per user: the last sign-in's time, country, city and coordinates, and the last 10 device keys and countries. Erasing the user removes it; it also goes after 180 days without a sign-in. Consumer VPNs are not detected. ### New-login alerts Starter and Pro. With alerts on under Settings, Sign-in safety, a recorded sign-in from a new device or country (not the user's first) sends the user a notification: a push to their devices, or, when they have none, an email from your verified sending domain to the `email` you passed, counted as an email code. Write the title and text yourself with `{{app}}`, `{{browser}}`, `{{os}}`, `{{city}}`, `{{country}}` and `{{time}}`. The answer says how it went (`alert.sent` is `push`, `email` or null with a reason), and `signin.alerted` goes to your webhook. ### Notification inbox Starter and Pro, Rs 0.003 a stored message. Add `inbox: true` to a push, or store one with `POST /v1/inbox`, and each user gets a row your app can list, mark read and remove. The app reads with the publishable key and an inbox token your server makes for the signed-in user, sent as the `SignOps-Inbox-Token` header; the token names the user, so one user cannot read another's inbox. Kept 30 days on Starter and 90 on Pro. - POST `/v1/inbox/token`: Server: a token for the app, for one user - POST `/v1/inbox`: Server: store a message without a push - GET `/v1/users/:user_id/inbox`: Server: a user's inbox - GET `/v1/inbox`: App: the inbox, with SignOps-Inbox-Token - POST `/v1/inbox/read`: App: mark ids, or all, read - DELETE `/v1/inbox/:id`: App: remove one ```node // Starter and Pro. Keep a push in the user's inbox too: await signops.push.send({ user_id: "usr_8f21c4", title: "Your bill is ready", body: "Rs 499 for October", link: "https://kiranaexpress.in/bills/10", inbox: true, }); // Your server, for the signed-in user: a token for the app. const { token } = await signops.inbox.token("usr_8f21c4"); ``` ```web import { configure, inbox } from "https://api.signops.dev/web.js"; configure({ key: "pk_live_..." }); // A ready-made list: unread marked, tap to read and open. inbox.mount(document.getElementById("inbox"), { token, // from your server onUnread: (n) => badge(n), }); ``` The browser helper draws a ready-made list (`inbox.mount`); the Android, iOS, React Native and Flutter SDKs have `inbox.list`, `read`, `readAll` and `remove` for your own screen. ### Live Activities and ongoing notifications Starter and Pro, Rs 0.002 an update (start and end count). For order and ride tracking: one notification that changes in place. Your server starts, updates and ends it by `ref`, your own id. On iOS the app starts the Live Activity with ActivityKit and the SDK sends its push token; updates then go straight to Apple, so the Apple key (`.p8`) must be uploaded under Push, iOS. On Android the SDK draws one ongoing notification per ref from the updates. - POST `/v1/activities`: Start an activity for a user, by your ref - POST `/v1/activities/:ref/update`: New state, progress or an alert - POST `/v1/activities/:ref/end`: End it, with its last state - GET `/v1/activities/:ref`: Status and the latest state - POST `/v1/activities/:ref/token`: iOS SDK: the Live Activity's push token - `title``body``progress`: What Android shows; 0 to 100 draws a progress bar. - `content_state`: iOS: the object your `ContentState` decodes, with the same keys. - `alert`: iOS: `{ title, body }` to light the screen for this update. Sent at Apple's high priority. - `stale_at``dismiss_at`: iOS: when the activity looks out of date, and when an ended one leaves the lock screen. ```node // Starter and Pro. One activity per ref, your own id. await signops.activities.start({ ref: "order_88213", user_id: "usr_8f21c4", title: "Preparing your order", progress: 10, content_state: { eta: 20 }, // iOS: your ContentState }); await signops.activities.update("order_88213", { title: "Ravi is on the way", progress: 60, content_state: { eta: 8 }, }); await signops.activities.end("order_88213", { title: "Delivered" }); ``` ```swift // iOS: start the Live Activity, then give SignOps its token. let activity = try Activity.request( attributes: OrderAttributes(orderId: "88213"), content: .init(state: .init(eta: 20), staleDate: nil), pushType: .token) signops.activities.register(ref: "order_88213", activity: activity) ``` ```kotlin // Android: in your FirebaseMessagingService. override fun onMessageReceived(m: RemoteMessage) { // Draws activities and pushes with buttons. False for the rest. if (SignOps.handleMessage(this, m.data, R.drawable.ic_notification)) return // ...your own pushes } ``` ### Webhooks Add an endpoint in Settings. Every event is posted as JSON with a `SignOps-Signature` header: an HMAC SHA-256 of the timestamp and the body, keyed with your endpoint's secret. Answer with any 2xx within 10 seconds. Failed deliveries are retried for 24 hours with backoff. ```node // Every event is signed. Check it, then act on it. const raw = express.raw({ type: "application/json" }); app.post("/signops", raw, (req, res) => { const event = signops.webhooks.verify( req.body, req.headers["signops-signature"], ); if (event.type === "device.removed") db.tokens.delete(event.data.device_id); if (event.type === "approve.denied") alertSecurityTeam(event.data.user_id); res.sendStatus(200); }); ``` ```json { "id": "evt_9d2kq81", "type": "approve.approved", "created": "2026-10-24T09:41:04+05:30", "data": { "challenge_id": "apr_51mz0c", "user_id": "usr_8f21c4", "device": "Pixel 8", "matched": true, "biometric": "face" } } ``` ### Event types | Event | When | | --- | --- | | push.sent | A message to a list, a topic or everyone was handed to FCM, APNs or the browser's push service. A push to one user answers with its result instead. | | push.failed | A device rejected a message. Carries the reason. | | push.opened | The user opened the push, or tapped a button (action). Needs the SDK in the app: every push carries its id as data.signops_id. | | device.removed | A token was removed because Google, Apple or the browser rejected it. | | approve.created | An approval request was sent to the user's trusted devices. | | approve.approved | The user matched the number and confirmed with a biometric. | | approve.denied | The user said it was not them. | | approve.expired | Nobody answered in 2 minutes. Not billed. | | passkey.created | A user made a new passkey. | | passkey.used | A user signed in with a passkey. | | totp.enrolled | A user confirmed an authenticator app with its first code. | | totp.backup_code_used | A user signed in with a backup code. Carries how many are left. | | totp.removed | A user's authenticator was removed, from your server or the dashboard. | | user.locked | Five wrong codes in 15 minutes locked a user. Carries the method, locked_until and which lock this is. | | user.deleted | A user was erased. Carries only the user id, the counts of what was removed and who asked. | | consent.recorded | A consent answer was recorded. Carries the purpose, granted and source. | | otp.sent | A code was emailed. | | otp.verified | A code was checked and was right. | | otp.failed | Five wrong tries, or the code expired. | | magic_link.sent | A sign-in link was emailed. | | magic_link.verified | A link's token was checked and was right. | | magic_link.failed | Five wrong tokens for one link. | | email.bounced | An address bounced and is now suppressed for your app. | | email.complained | A user marked a code email as spam, and the address is now suppressed for your app. | | email.warning | Your app's bounces, spam complaints or unused codes are past the warning line. Carries the topic and the numbers. | | email.paused | Email codes for your app are paused for 24 hours, or stopped after a third pause in 30 days. Carries the reason and until. | | usage.limit | A Free plan amount reached 80% or 100%. Carries the metric, level, used and limit. | | signin.alerted | A new-login alert went to the user, by push or email. Carries new_device, new_country, country and city. | ### Limits | What | Limit | | --- | --- | | API requests | 100 a second per app, bursts to 200. Test keys on Free: 10 a second. | | Test keys on Free | Half of each Free amount, counted apart from live: 500 test devices, 50 approvals, 500 passkey sign-ins and 5,000 authenticator checks a month, 50 test codes a day. Then 403 (429 for codes) until the 1st, IST, or midnight for codes. Starter and Pro test keys are not limited this way. | | Push pacing | Up to 5,000 pushes a second per app. | | Push fair use | Lists, topics and everyone: an average of 10 pushes per device per day over a month. Pushes to one user: 3 per device per day across the app, at least 1,500 a day, up to a day's worth at once, then 429 with Retry-After. | | User ids per push | 1,000. Use a topic for more. | | Devices per user | 20 for push, 5 trusted for Approve, 10 passkeys. | | Message size | 4 KB, the limit Google and Apple set. | | send_at | Up to 30 days ahead. | | Approval expiry | 2 minutes. | | Code expiry and tries | 10 minutes, 5 tries, 30 seconds between resends. | | Authenticator codes | One authenticator a user. A code is accepted 30 seconds early or late, and once. 5 wrong codes in 15 minutes lock the user for 1 minute, doubling to 1 hour. | | Authenticator checks | 10,000 a month on Free, then 403 after 10% grace until the 1st, IST. 1 lakh on Starter and 5 lakh on Pro, then Rs 100 per 50,000. Every answered verify counts, right or wrong. | | Passkey sign-ins | 1,000 a month on Free, then 403 until the 1st, IST. 50,000 on Starter and 2 lakh on Pro, then Rs 100 per 50,000. Registering a passkey is not counted. | | Free refills | Rs 50 gives a Free organisation the month's limits again (devices, approvals, passkey sign-ins, authenticator checks), up to twice a month, from Settings, Billing. Email codes are not refilled. | | Magic links | 15 minutes, once, 5 wrong tokens. They count with email codes toward every limit below and the daily cap, and are priced as codes. | | Codes per address | 5 an hour, 30 seconds apart. Per IP address (when you pass ip), 20 an hour. Both can be changed per app. | | Email codes a day | Level 1 (new): 1,000. Level 2, after 7 days and 1,000 clean codes: 10,000. Level 3, after 30 days and 25,000: 1 lakh. More on request. Up to 100 a minute. Test keys: 100 a day (50 on Free), to your test addresses only. | | Email sending health | A warning at 2% bounces, 0.05% spam complaints, or fewer than 25% of a day's codes typed back; a 24-hour pause at 5%, 0.1% or under 10%; a stop after three pauses in 30 days. | | Starter and Pro features a day | Per organisation, live and test together, to midnight IST. Starter: 3,000 inbox messages stored, 5,000 activity updates, 10,000 sign-ins with risk signals, 5,000 verify sessions. Pro: 10,000, 15,000, 30,000 and 15,000. Then 429 daily_cap; email us for more. | | Inbox | Kept 30 days on Starter, 90 on Pro. A token lasts 24 hours by default, up to 7 days. Reads by the app are not logged. | | Live Activities | One activity per ref. Apple ends a Live Activity after 8 hours; SignOps forgets it a day after it ends or 12 hours after its last update. Updates without an alert go at Apple's low priority, inside its budget. | | Verify sessions | 10 minutes. Steps run passkey, Approve, authenticator, email; leave steps out with steps. | | Logs | None on Free, 7 days on Starter, 30 on Pro. Approval records 7 days on Free, 90 on Starter, 1 year on Pro. A live push to one user from your server is answered with its result; it is kept in the logs (and found by GET /v1/push/:id, with open counts) only when it failed on some device, on Starter and Pro. Lists, topics, scheduled pushes, composer sends and test mode are always kept. | `GET /v1/health` needs no key. It answers 200 with `ok: true` when the database answers, the scheduler ran in the last five minutes and no send is stuck, and 503 otherwise, so an uptime monitor can watch it. ### Android delivery in India Xiaomi, Redmi, Poco, Oppo, Realme, Vivo and OnePlus phones stop apps in the background to save battery, which delays or drops pushes from any provider. SignOps sends high priority when the message is one the user expects, like an approval or a delivery update. In your app: - Ask for the notification permission at a moment the user understands, not at first launch. - For apps where pushes matter, like banking or delivery, show a one-time screen that opens the phone's battery settings for your app. - Use one notification channel for approvals and another for offers, so users can mute offers without missing logins. - Call `Approve.pending()` when the app opens, so a held approval still reaches the user. Something missing? Email [hello@signops.dev](mailto:hello@signops.dev) and the answer goes into these docs. # Guides, comparisons and posts ## Which product to use, and when URL: https://signops.dev/guides/which-product/ Push, Approve, passkeys, authenticator codes and email OTP matched to the jobs they do: sign-up, sign-in, two-step, payments, recovery and notifications. SignOps has five products. Each does one job well, and most apps use two or three of them together. This page matches them to the jobs, so you set up only what you need. ### The five products in one line each | Product | What the user does | What your app needs | | --- | --- | --- | | [Push](https://signops.dev/push/) | Gets a notification on Android, iOS or the web | Firebase or an Apple key, and the device token | | [Email OTP](https://signops.dev/email-otp/) | Types a 6-digit code from an email, or taps a link | Your own sending domain, with five DNS records | | [Passkeys](https://signops.dev/passkeys/) | Signs in with a fingerprint, face or screen lock | Your domain, and the browser script or the phone SDK | | [Authenticator](https://signops.dev/authenticator/) | Types a code from Google Authenticator or a similar app | Nothing in the app: a page with a QR code | | [Approve](https://signops.dev/approve/) | Taps the number on a phone that is already signed in | The phone SDK in your app, and push set up | ### Match the job to the product | The job | Use | Why | | --- | --- | --- | | Confirm an email at sign-up | Email OTP | The user has nothing else set up yet | | Sign in without a password | Passkeys, with Email OTP or a magic link as the fallback | A passkey cannot be phished; the email covers new devices | | A second step after the password | Passkeys or Authenticator | Both cost less than SMS and need no DLT registration | | A second step for users who refuse apps | Email OTP | Everyone has an email inbox | | Approve a sign-in on a new laptop | Approve | The user’s phone shows the request and a number to match | | Confirm a payment, a payout or a password change | Approve, or Passkeys | The user confirms on a device you already trust | | Recover an account after a lost phone | Email OTP, then authenticator backup codes | Approve and device passkeys go with the phone | | Order updates, reminders, offers | Push | Free per message, sent to a user, a topic or everyone | | Tell users about a new sign-in | Push | A message to their own devices (new-login alerts on Starter and Pro) | ### Recipes for common apps **A website with email and password today.** Add Email OTP for sign-up and password reset. Offer a passkey after the next sign-in (“Sign in faster next time”). Offer the authenticator app on the security settings page for users who want a second step. **A mobile app with phone-number login by SMS.** Keep SMS for the very first sign-in if you must, and move every later sign-in to a passkey in the app. Use Approve when the same user signs in on the website: their phone gets the request. Use Push for order and account updates. Every sign-in that moves off SMS saves the SMS price. **A fintech or payments app.** Passkeys or Approve on every sign-in, Approve for each payment above your own limit, and the authenticator app with backup codes as the fallback. The [RBI’s 2025 directions](https://signops.dev/blog/rbi-authentication-directions-2025/) accept these as factors other than SMS OTP. **A B2B dashboard.** Email OTP or a magic link to sign in, the authenticator app as the second step (admins expect it), and passkeys for users who want them. ### The order to set things up 1. **Get your keys.** [Sign up](https://app.signops.dev/signup). Every app has a test key (`sk_test_`) that works at once and a live key (`sk_live_`) that works after your account is approved. Secret keys stay on your server. The publishable key (`pk_live_`) is the only one that goes in an app or a web page. 2. **Pick your first product** from the table above and follow its guide: - [Push on Android](https://signops.dev/guides/android-push/), [on iOS](https://signops.dev/guides/ios-push/), [on the web](https://signops.dev/guides/web-push/), or [in React Native, Expo and Flutter](https://signops.dev/guides/react-native-flutter-push/) - [Email OTP and magic links](https://signops.dev/guides/email-otp/) - [Passkeys on your website](https://signops.dev/guides/passkeys-web/), then [in your Android and iOS apps](https://signops.dev/guides/passkeys-apps/) - [Authenticator app codes](https://signops.dev/guides/authenticator/) - [Approve](https://signops.dev/guides/approve/) (set up push first: the request reaches the phone as a push) 3. **Add a webhook** under Webhooks in the dashboard, so your server hears about failed pushes, approvals, locks and bounces without asking. 4. **Test with the test key**, then swap in the live key. Test keys reach only devices and addresses you mark as test, and nothing they do is billed. ### One user id everywhere Every product works under your own user id: the one your database already has, any string up to 128 characters. SignOps never makes one for you. That is what lets a user’s devices, passkeys, authenticator and approvals sit together, and what [Delete, export and consent](https://signops.dev/docs/#users) erase in one call when the user asks. ### Questions #### Can I use more than one SignOps product in the same app? Yes. They share one API key, one dashboard and your own user ids, so a user who signs up with an email code can later add a passkey, trust a phone for Approve and turn on an authenticator app, all under the same user_id. #### Which product should a new sign-up use? An email code (or a magic link). It is the only one that works before the user has anything set up. Passkeys, Approve and the authenticator app are added after the first sign-in. #### Do I need an SDK in my app? Not for push, email codes, magic links or the authenticator app. Push uses the Firebase or Apple SDK your app already has, and the rest are calls from your server. Approve and passkeys inside an Android or iOS app need the SignOps phone SDK. Passkeys on a website need only the browser script. #### Does SignOps replace my login system? No. Your login system keeps its users and sessions. SignOps checks that the person is the user, by a code, a passkey or an approval, and your server then starts the session as it does today. ## Set up push on Android URL: https://signops.dev/guides/android-push/ From a new Firebase project to a push on your Android phone: google-services.json, the service account key, the device token, and a first send. This guide takes an Android app from no push at all to a notification on your own phone. You need a Google account, Android Studio and your SignOps test key. It takes about 20 minutes. How it fits together: your app gets a token from Firebase and gives it to **your** server. Your server adds it to SignOps under your own user id. From then on, your server sends to the user id, and SignOps hands the message to Firebase with your project’s key. ### 1. Make a Firebase project 1. Open the [Firebase console](https://console.firebase.google.com/) and choose **Create a project** (or open the one your app already uses). Google Analytics is optional; push does not need it. 2. On the project’s home page, choose **Add app**, then the **Android** icon. 3. Type your app’s **package name**: the `applicationId` in `app/build.gradle.kts`, for example `com.kiranaexpress.app`. It must match exactly. 4. Download **google-services.json** and put it in your app module folder (`app/`), next to `build.gradle.kts`. ### 2. Add Firebase Messaging to the app The Firebase setup screen shows the current version numbers; use those. ```kotlin // build.gradle.kts (project) plugins { id("com.google.gms.google-services") version "" apply false } // app/build.gradle.kts plugins { id("com.google.gms.google-services") } dependencies { implementation(platform("com.google.firebase:firebase-bom:")) implementation("com.google.firebase:firebase-messaging") } ``` ### 3. Ask for permission and get the token Android 13 and later asks the user before an app can show notifications. Ask at a moment the user understands, such as after they place an order, not at first launch. ```xml ``` ```kotlin // After sign-in, once the user allows notifications if (Build.VERSION.SDK_INT >= 33) { requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1) } FirebaseMessaging.getInstance().token.addOnSuccessListener { token -> api.saveDevice(token) // your own endpoint, with the signed-in user } // PushService.kt: Firebase changes the token now and then class PushService : FirebaseMessagingService() { override fun onNewToken(token: String) { api.saveDevice(token) } } ``` Make the `orders` notification channel at app start with `NotificationManager.createNotificationChannel`. A push can name another channel with `android_channel`. ### 4. Add the token on your server Your endpoint (`api.saveDevice` above) knows who is signed in. It adds the token under that user’s id. Adding the same token again only updates it. ```bash curl https://api.signops.dev/v1/devices \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -d user_id=usr_8f21c4 -d platform=android -d token=$FCM_TOKEN ## -> { "id": "dev_...", "user_id": "usr_8f21c4", "platform": "android", ... } ``` Keep the returned `id`. When the user signs out on that phone, remove it with `DELETE /v1/devices/:id` so they stop getting their pushes there. With the Node library this is `signops.devices.add({ user_id, platform: "android", token })`. ### 5. Give SignOps your Firebase service account 1. In the Firebase console, open the gear icon, **Project settings**, then the **Service accounts** tab. 2. Choose **Generate new private key**. A JSON file downloads. Keep it private: it can send to every user of the project. 3. In the [SignOps dashboard](https://app.signops.dev), open **Push**, **Setup**, and choose **Set up** on Android. 4. Upload the JSON file. SignOps checks it with Google before saving, then shows the project id and **Google accepted the key**. The same service account sends to iOS apps that use Firebase too (see [iOS push](https://signops.dev/guides/ios-push/)). ### 6. Send a test In the same Android panel, type the user id from step 4 under **Send a test** and choose **Send test push**, then **See what Google said**. Or from your server: ```bash 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.","android_channel":"orders"}' ## -> { "status": "sent", "devices": 1, "sent": 1, "failed": 0 } ``` A push to one user is sent before the API answers, so `sent` and `failed` are the real result. Pushes to a topic or to everyone answer `sending`, and the result reaches your webhook. Test and live keys keep separate device lists. A token your server added with the test key gets pushes sent with the test key, and nothing else does. When you switch your server to the live key, devices are added to the live list as users open the app. ### 7. Before you ship - **Close the app and send again.** Pushes that arrive only while the app is open usually mean the phone is stopping it in the background. Xiaomi, Oppo, Vivo and Realme do this by default; see [why pushes do not arrive on those phones](https://signops.dev/blog/push-notifications-xiaomi-oppo-vivo/). - **Open the app with a push.** Put a deep link in `link`, and read it from the launch intent. - **Know when tokens die.** When Google says a token is no longer valid (the app was uninstalled), SignOps removes it and posts `device.removed` to your webhook. - **Count opens** (optional). The SignOps Android SDK reports opens with `signops.push.opened(intent)`. It is not on Maven Central yet; the call it makes is `POST /v1/push/:id/opened`, with the id every push carries as `signops_id`. ### When something goes wrong | What you see | What it means | | --- | --- | | `SENDER_ID_MISMATCH` | The token is from another Firebase project than the service account | | `UNREGISTERED` | The app was uninstalled or its data cleared; the token is removed | | `sent: 1` but nothing on the phone | The app is in the foreground, notifications are off for the app, or the phone is stopping it in the background | | Upload refused | The file is not a service account key (a `google-services.json` is not one) | Every other code Google can answer is in [FCM error codes](https://signops.dev/blog/fcm-error-codes/). When and how to ask for the permission: [the Android 13 notification permission](https://signops.dev/blog/android-notification-permission/). ### Questions #### Do I need the SignOps SDK for push on Android? No. The app uses Firebase Cloud Messaging as usual and gives its token to your server. SignOps sends with your Firebase project's service account. The SignOps SDK is only needed for Approve, passkeys and counting opens. #### Why does SignOps need my Firebase service account? A device token belongs to one Firebase project, and only that project's credentials can send to it. The service account JSON is how SignOps sends as your project. It is checked with Google on upload, encrypted with AES-256-GCM, and never shown again. #### What does SENDER_ID_MISMATCH mean? The token was made by a different Firebase project from the service account you uploaded. Check that google-services.json in the app and the service account in SignOps come from the same Firebase project. #### My push arrives when the app is closed but not when it is open. Why? When the app is in the foreground, Android hands a notification message to your FirebaseMessagingService.onMessageReceived instead of showing it. Draw it yourself there with NotificationCompat if you want it shown. ## Set up push on iOS URL: https://signops.dev/guides/ios-push/ How to make an APNs .p8 key, find the Key ID, Team ID and bundle id, turn on push in Xcode, get the device token and send a first push. This guide takes an iOS app from no push to a notification on your own iPhone. You need a paid Apple Developer Program membership, Xcode, a real iPhone and your SignOps test key. It takes about 30 minutes. ### First, pick a route iOS pushes can reach Apple in two ways. Pick one in the dashboard under **Push**, **Setup**, **iOS**. | | Straight to Apple (APNs) | Through Firebase | | --- | --- | --- | | Pick it when | Your iOS app does not use Firebase | Your iOS app already uses Firebase Messaging | | SignOps needs | Your `.p8` key, Key ID, Team ID and bundle id | Your Firebase service account (the one for Android) | | The app sends | Its APNs device token | Its FCM token | | Live Activities | Yes | No: they need the `.p8` key in SignOps | Both start with the same Apple key. The steps below are for **Straight to Apple**; the Firebase route differs only in step 4. ### 1. Make an APNs key (.p8) 1. Sign in at [developer.apple.com/account](https://developer.apple.com/account) and open **Certificates, Identifiers and Profiles**, then **Keys**. 2. Choose **+** (Create a key). Give it a name such as `SignOps push`. 3. Tick **Apple Push Notifications service (APNs)**. If Apple asks you to configure it, choose the environment **Sandbox and Production**, and a key for your whole team rather than one topic, so the same key works for every build and app. 4. Choose **Continue**, then **Register**. 5. Choose **Download**. You get a file named `AuthKey_8K2Q9XW4LM.p8`. **Apple lets you download it once**, so keep it somewhere safe, such as your password manager. 6. Note the **Key ID** on that page: the 10 characters after `AuthKey_` in the file name. Apple limits how many APNs keys a team can have. If your team already has one (for Firebase or another provider), you can reuse its `.p8` file instead of making another. ### 2. Find your Team ID and bundle id - **Team ID**: [developer.apple.com/account](https://developer.apple.com/account), **Membership details**. Ten characters, such as `5HD3K7P2TQ`. - **Bundle id**: in Xcode, select your app target, **General**, **Bundle Identifier**, such as `com.kiranaexpress.app`. It must match the app exactly; it is the topic Apple delivers to. ### 3. Upload the key to SignOps 1. In the [SignOps dashboard](https://app.signops.dev), open **Push**, **Setup**, choose **Set up** on iOS, then **Straight to Apple (APNs)**. 2. Choose the `.p8` file. The Key ID fills itself from the file name. 3. Type the **Team ID** and **Bundle ID**. 4. Leave **Environment** on **Production and sandbox**. 5. Choose **Save key**. SignOps encrypts it with AES-256-GCM and never shows it again. ### 4. Turn on push in Xcode 1. Select your app target, **Signing and Capabilities**, choose **+ Capability** and add **Push Notifications**. With automatic signing, Xcode turns push on for your App ID. 2. Add **Background Modes** and tick **Remote notifications** if you send silent pushes (pushes with `data` and no title). ### 5. Ask for permission and get the device token ```swift import UIKit import UserNotifications class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { // Call this after sign-in, at a moment the user understands func askForPush() { UNUserNotificationCenter.current().delegate = self UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in guard granted else { return } DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() } } } func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) { let hex = token.map { String(format: "%02x", $0) }.joined() api.saveDevice(hex) // your own endpoint, with the signed-in user } func application(_ app: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { print("No push token:", error) // usually the Push Notifications capability is missing } // Show pushes while the app is open too func userNotificationCenter(_ c: UNUserNotificationCenter, willPresent n: UNNotification) async -> UNNotificationPresentationOptions { [.banner, .sound, .badge] } } ``` In a SwiftUI app, attach it with `@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate`. Call `registerForRemoteNotifications()` at every launch: the token can change, and adding it again is safe. **Through Firebase instead:** upload the same `.p8` to the Firebase console (**Project settings**, **Cloud Messaging**, **Apple app configuration**), then send the FCM token from `Messaging.messaging().token` instead of the hex device token. SignOps does not need the `.p8` on this route. ### 6. Add the token on your server ```bash curl https://api.signops.dev/v1/devices \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -d user_id=usr_8f21c4 -d platform=ios -d token=$APNS_TOKEN ``` Keep the returned `id` and remove it with `DELETE /v1/devices/:id` when the user signs out on that phone. Test and live keys keep separate device lists: a token added with the test key gets pushes sent with the test key. ### 7. Send a test Run the app on your iPhone from Xcode, allow notifications, and lock the phone. Then, in the iOS panel, type the user id under **Send a test**, or from your server: ```bash 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.","sound":"default","badge":1}' ``` **See what Apple said** in the panel shows Apple’s answer for each device. ### When something goes wrong | Apple’s answer | What it means | | --- | --- | | `BadDeviceToken` | A sandbox token sent to production or the other way round. Set Environment to Production and sandbox | | `DeviceTokenNotForTopic` | The bundle id in SignOps is not the app’s bundle id | | `InvalidProviderToken` | The Key ID or Team ID does not match the `.p8` file, or the key was revoked | | `Unregistered` | The app was removed from the phone. SignOps removes the token and posts `device.removed` | | No token at all | The Push Notifications capability is missing, or the app runs in a Simulator that cannot get one | Every reason Apple can answer is in [APNs error codes](https://signops.dev/blog/apns-error-codes/). ### Next - Buttons on iOS come from a notification category your app registers. See [Buttons and sounds](https://signops.dev/docs/#rich). - Order and ride tracking that updates in place: [Live Activities](https://signops.dev/docs/#activities) (Starter and Pro). - The same app on Android: [Android push](https://signops.dev/guides/android-push/). - Check the key and token by hand first: [send a test push with curl](https://signops.dev/blog/apns-curl/). ### Questions #### Where do I find my Apple Team ID? Sign in at developer.apple.com/account and open Membership details. The Team ID is a 10-character code such as 5HD3K7P2TQ. Xcode shows the same team under Signing and Capabilities. #### I lost the .p8 file. Can I download it again? No. Apple lets you download an APNs key once. Revoke it under Certificates, Identifiers and Profiles, Keys, make a new one and upload that to SignOps. Pushes keep working with the old key until you revoke it. #### Should I pick sandbox or production? Pick Production and sandbox. Builds you run from Xcode get sandbox tokens; TestFlight and App Store builds get production tokens. With both, SignOps tries production first and falls back to sandbox, so one setting covers every build. #### Do I need Firebase for iOS? No. If your iOS app does not use Firebase, send straight to Apple with a .p8 key. If it already uses Firebase Messaging, send through Firebase and use the same service account as Android. #### Does push work in the iOS Simulator? Do your first test on a real iPhone. The Simulator receives remote pushes only on some Macs and Xcode versions, and its tokens are sandbox tokens. ## Set up web push URL: https://signops.dev/guides/web-push/ Web push with keys SignOps makes for you: a service worker, the permission prompt, the subscription on your server, and iPhone home screen apps. Web push works in Chrome, Edge, Firefox and Safari on Android, Mac, Windows and Linux, and on iPhone once your site is on the home screen. There is nothing to upload: SignOps made the web push keys for your app. You need a site on https (or `localhost` while you build) and about 15 minutes. ### 1. Try it in your own browser first In the [SignOps dashboard](https://app.signops.dev), open **Push**, **Setup**, **Web**, and choose **Add this browser**. Allow notifications, then **Send test push**. You now know the keys work and what a push looks like before writing any code. ### 2. Copy your public key In the same panel, copy **Your public key**. It is safe in page code. Each app has its own, so a test app and your live app have different keys. ### 3. Add a service worker A service worker is the small script that runs when a push arrives, even with your site closed. Put `sw.js` at the root of your site, so it controls every page: ```js // https://your-site.com/sw.js self.SIGNOPS_KEY = "pk_live_..."; // your publishable key; leave it out to skip open counts importScripts("https://api.signops.dev/push-sw.js"); ``` `push-sw.js` draws the notification with its image and buttons, opens the push’s `link` (or the tapped button’s) and reports the open so it shows in the dashboard and as a `push.opened` webhook. If your site already has a service worker, add the two lines to it. ### 4. Subscribe on a button tap Ask from a button the user taps, after they understand why, such as “Tell me when my order ships”. Browsers ignore a prompt that is not caused by a tap. ```html ``` `push.subscribe` registers `/sw.js`, asks for permission and returns the subscription. Pass `serviceWorker: "/path/sw.js"` if yours lives elsewhere. ### 5. Add the subscription on your server The subscription object is the token for the web. Your endpoint adds it under the signed-in user: ```bash curl https://api.signops.dev/v1/devices \ -H "Authorization: Bearer $SIGNOPS_KEY" \ -H "content-type: application/json" \ -d '{"user_id":"usr_8f21c4","platform":"web","token":{"endpoint":"https://fcm.googleapis.com/fcm/send/...","keys":{"p256dh":"...","auth":"..."}}}' ``` With the Node library: `signops.devices.add({ user_id, platform: "web", token: subscription })`. ### 6. Send ```bash 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 shipped","body":"Arriving Thursday.","link":"https://your-site.com/orders/88213"}' ``` The same call reaches the user’s Android and iOS apps too, if they have them. ### iPhone and iPad Safari on iOS 16.4 and later gets web pushes only for sites added to the home screen: 1. Add a web app manifest (`manifest.json` with `name`, `icons` and `"display": "standalone"`) and link it from your pages. 2. Tell iPhone users to tap **Share**, then **Add to Home Screen**, and open the site from that icon. 3. Show your **Turn on notifications** button there. The prompt works only from the home screen app. ### When something goes wrong | What you see | What it means | | --- | --- | | The button does nothing | Notifications were blocked for the site earlier. The user allows them in the browser’s site settings | | `sent: 1` but no notification | The computer is in Do Not Disturb or Focus, or the browser’s own notifications are off in the operating system. Other causes in [web push received but not shown](https://signops.dev/blog/web-push-not-showing/) | | The device disappears | The browser dropped the subscription (site data cleared, or permission removed). SignOps removes it and posts `device.removed` | | Subscriptions from another provider fail | They were made with that provider’s keys. Users subscribe again | Test your own browser’s support without code with the [web push tester](https://signops.dev/tools/web-push-test/). Every status a push service can answer is in [web push errors](https://signops.dev/blog/web-push-errors/) (a 403 can be checked with the [VAPID key checker](https://signops.dev/tools/vapid-keys/)), and the iPhone details in [web push on iOS](https://signops.dev/blog/web-push-ios/). ### Questions #### Do I need Firebase for web push? No. SignOps made a pair of web push (VAPID) keys for your app when you created it. The public key goes in your page; the private key stays with SignOps, encrypted. #### Does web push work on iPhone? Yes, on iOS 16.4 and later, once the user adds your site to the home screen and opens it from there. Safari in a normal tab cannot subscribe. #### Can I move web push subscribers from OneSignal or Firebase? No. A browser subscription is tied to the web push keys it was made with, so it only works with the old provider's keys. Those users subscribe again on your site, which happens the next time they allow notifications. #### Why does the permission prompt not appear? Browsers only show it after a click or tap, block it for sites that ask too often, and remember a No. Ask from a button such as Turn on notifications, and if the user said No, tell them how to allow it in the site settings. ## Set up push in React Native, Expo and Flutter URL: https://signops.dev/guides/react-native-flutter-push/ Use the Firebase messaging package your framework already has, register the token with SignOps, and send to Android and iOS from one call. React Native, Expo and Flutter apps use the Firebase or Expo package they already have to get a token. Your server adds that token to SignOps, and one call sends to Android and iOS. The SignOps SDK is not needed for push. Before you start, do the account half of the native guides, which is the same for every framework: - **Android**: a Firebase project with your Android app, and its service account uploaded under **Push**, **Setup**, **Android** ([Android guide](https://signops.dev/guides/android-push/), steps 1 and 5). - **iOS**: an APNs `.p8` key ([iOS guide](https://signops.dev/guides/ios-push/), step 1), and the Push Notifications capability in Xcode (step 4). ### Which token each setup gives | Framework and package | Android token | iOS token | iOS route in SignOps | | --- | --- | --- | --- | | React Native with `@react-native-firebase/messaging` | FCM | FCM | Through Firebase (upload the `.p8` to Firebase) | | Flutter with `firebase_messaging` | FCM | FCM | Through Firebase (upload the `.p8` to Firebase) | | Expo with `expo-notifications`, `getDevicePushTokenAsync` | FCM | APNs | Straight to Apple (upload the `.p8` to SignOps) | Set the route under **Push**, **Setup**, **iOS** before your first iPhone registers. ### React Native ```sh npm install @react-native-firebase/app @react-native-firebase/messaging cd ios && pod install ``` Put `google-services.json` in `android/app/` and `GoogleService-Info.plist` in the iOS project (both from the Firebase console, one app each). Then, after sign-in: ```tsx import messaging from "@react-native-firebase/messaging"; import { PermissionsAndroid, Platform } from "react-native"; export async function turnOnPush() { if (Platform.OS === "android" && Platform.Version >= 33) { await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS); } await messaging().requestPermission(); // iOS asks here; Android returns at once const token = await messaging().getToken(); await api.saveDevice({ token, platform: Platform.OS }); // "android" or "ios" messaging().onTokenRefresh((t) => api.saveDevice({ token: t, platform: Platform.OS })); } ``` ### Expo Expo push tokens (`ExponentPushToken[...]`) only work with Expo’s service. Ask for the phone’s own token instead: ```tsx import * as Notifications from "expo-notifications"; import { Platform } from "react-native"; export async function turnOnPush() { const { status } = await Notifications.requestPermissionsAsync(); if (status !== "granted") return; const { data: token } = await Notifications.getDevicePushTokenAsync(); // FCM on Android, APNs on iOS await api.saveDevice({ token, platform: Platform.OS }); } ``` In `app.json`, set `android.googleServicesFile` to your `google-services.json`, then make a development build (`npx expo prebuild`, or EAS Build). Expo Go cannot use your own Firebase project. If pushes worked in development and stopped in the APK, see [Expo push notifications not working](https://signops.dev/blog/expo-push-notifications-not-working/). A runnable app and server: [expo-push on GitHub](https://github.com/signops-dev/signops-examples/tree/main/expo-push). ### Flutter ```sh flutter pub add firebase_core firebase_messaging dart pub global activate flutterfire_cli flutterfire configure # adds Android and iOS apps to your Firebase project ``` In Xcode (`ios/Runner.xcworkspace`), add the **Push Notifications** capability to the Runner target. Then, after sign-in: ```dart import 'dart:io'; import 'package:firebase_messaging/firebase_messaging.dart'; Future turnOnPush() async { final fm = FirebaseMessaging.instance; await fm.requestPermission(); // iOS and Android 13 and later final token = await fm.getToken(); final platform = Platform.isIOS ? 'ios' : 'android'; if (token != null) await api.saveDevice(token, platform); fm.onTokenRefresh.listen((t) => api.saveDevice(t, platform)); } ``` ### Your server, for all three `api.saveDevice` is your own endpoint. It knows the signed-in user and adds the token: ```js // Node, with npm install @signops/node import { SignOps } from "@signops/node"; const signops = new SignOps(process.env.SIGNOPS_KEY); app.post("/api/devices", requireUser, async (req, res) => { const device = await signops.devices.add({ user_id: req.user.id, platform: req.body.platform, token: req.body.token }); res.json({ id: device.id }); // keep it, to remove the device when the user signs out }); // Later, from anywhere on your server await signops.push.send({ user_id: "usr_8f21c4", title: "Your order is out for delivery", body: "Ravi is 10 minutes away." }); ``` Then send a test from **Push**, **Setup** in the dashboard with the user id, or with the call above, and check **Push**, **Messages** for what Google and Apple said. ### Using Approve or passkeys too Those need the SignOps module in the app (`@signops/react-native` or the `signops` Flutter plugin), which reuses the Firebase token you already have. It is not on npm or pub.dev yet; see the [Approve guide](https://signops.dev/guides/approve/) for what it does and the calls it makes. ### Questions #### Can I send Expo push tokens (ExponentPushToken) through SignOps? No. Those tokens only work with Expo's own push service. Get the phone's native token with getDevicePushTokenAsync from expo-notifications instead, which gives an FCM token on Android and an APNs token on iOS. #### Does push work in Expo Go? Not for testing your own Firebase project and Apple key. Make a development build with npx expo prebuild or EAS Build, so the app carries your google-services.json and push capability. #### Which iOS route should a React Native or Flutter app pick? Through Firebase if the app gets its token from Firebase Messaging, which is what @react-native-firebase/messaging and firebase_messaging give you. Straight to Apple if it sends the raw APNs token, as Expo's getDevicePushTokenAsync does on iOS. ## Set up Approve URL: https://signops.dev/guides/approve/ Let users approve a sign-in or a payment on a phone they already use: enrol the phone after login, ask it from your server, and show the number to match. Approve sends a request to a phone where the user is already signed in to your app. The phone shows what is happening (“Sign in from Chrome on Windows, Pune”) and three numbers. The user picks the number shown on your login page and confirms with a fingerprint, face or screen lock. Your server gets `approved` in a few seconds. Use it for sign-ins on a new computer, payments, payouts, password and email changes, and anything else you would confirm with an SMS code today. ### What you need first - **An Android or iOS app** where users sign in. Approve works only on a phone that has signed in before; sign-up and a lost phone need another way in (email OTP, a passkey, or authenticator backup codes). - **Push set up** for that app, because the request reaches the phone as a push: [Android](https://signops.dev/guides/android-push/) (Firebase service account) and [iOS](https://signops.dev/guides/ios-push/) (`.p8` key or Firebase). - **The SignOps phone SDK**: Android (`com.signops:android`), iOS (`SignOps` on Swift Package Manager), React Native (`@signops/react-native`) or Flutter (`signops`). They are not on Maven Central, Swift Package Manager, npm or pub.dev yet. The REST calls they make are in the [API reference](https://signops.dev/docs/#approve), so you can see exactly what they do. - **Your publishable key** (`pk_live_...`), from **Approve**, **Setup** or **API keys**. It can only enrol a phone and answer approvals, so it is safe inside the app. ### 1. Trust the phone after your own login Once the user has signed in to your app on their phone, your server makes a one-time enrol token for them, and the app enrols: ```js // Your server, for the user who just signed in const { token } = await signops.approve.enrollToken({ user_id: "usr_8f21c4" }); // send token to the app ``` ```kotlin // Android val signops = SignOps(context, "pk_live_...") signops.approve.enroll(token, FirebaseMessaging.getInstance().token.await()) ``` ```swift // iOS let signops = SignOps(key: "pk_live_...") try await signops.approve.enroll(token: token, pushToken: SignOps.apnsToken(from: deviceToken)) // or the FCM token ``` The SDK makes a P-256 key pair in the Keystore or Secure Enclave, sends only the public half, and adds the push token as one of the user’s devices. Your dashboard’s **Users** page now shows the phone as trusted, and your webhook gets `device.trusted`. On iOS, add `NSFaceIDUsageDescription` to Info.plist. On Android, the activity that shows the prompt must be a `FragmentActivity` (`AppCompatActivity` is one). ### 2. Show the request when the push arrives ```kotlin // Android: in your FirebaseMessagingService override fun onMessageReceived(m: RemoteMessage) { Approve.requestId(m.data)?.let { signops.approve.open(this, it); return } // the ready-made screen // ... your other pushes } override fun onNewToken(t: String) { scope.launch { signops.approve.updatePushToken(t) } } ``` ```swift // iOS (SwiftUI): the ready-made screen .sheet(item: $approveId) { id in ApproveView(signops: signops, requestId: id.value) { _ in approveId = nil } } ``` Some phones hold pushes while the app sleeps, so also check when the app opens: `if (signops.approve.isEnrolled && signops.approve.pending().isNotEmpty()) signops.approve.open(this)`. To use your own screen instead, read `signops.approve.pending()` and call `approve(req, picked)` or `deny(req)`. ### 3. Ask the phone from your server When the user signs in on the website (or starts a payment), create a request and show its `match` number on that page: ```js const req = await signops.approve.create({ user_id: "usr_8f21c4", context: "Sign in from Chrome on Windows", // shown on the phone city: "Pune", }); // Show req.match ("47") on the login page: "Tap 47 on your phone" const result = await signops.approve.wait(req.id, { timeout: 120 }); if (result.status === "approved") startSession(result.user_id); // "denied": the user said it was not them. "expired": nobody answered in 2 minutes. ``` `wait` holds the connection for up to 120 seconds. If your server cannot hold a request that long, take the `approve.approved`, `approve.denied` and `approve.expired` webhooks instead, or poll `GET /v1/approve/:id`. For a payment, put the amount and the payee in `context`, such as `"Pay Rs 4,999 to Kirana Express"`, so the user approves exactly that. ### 4. Test it on your own phone 1. Install your app on your phone with your test publishable key, sign in, and let it enrol (step 1). 2. In the dashboard, open **Approve**, **Setup**, **Try it on a trusted phone**, type your user id and choose **Send a test approval**. 3. The phone shows the request. Pick the number the dashboard shows and confirm with your fingerprint or face. The dashboard changes to **Approved**. ### 5. Plan the ways back in Approve proves the user has their phone. When they do not: - **New phone, old one gone**: sign in with an email code or a passkey, then enrol the new phone. The old one can be untrusted from the **Users** page or with `signops.approve.untrust(deviceId)`. - **No trusted phone yet**: `create` answers 404; offer email OTP or the authenticator. - **Turn Approve off** for the whole app under **Approve**, **Settings**. Requests then answer 403 `not_allowed`, so your login page should fall back. To try several methods in a fixed order in one session (passkey, then Approve, then authenticator, then email), see [Verify in one call](https://signops.dev/docs/#verify) on Starter and Pro. ### Questions #### Why does Approve need code in my app? Each answer is signed with a key made inside the phone's Android Keystore or iOS Secure Enclave, and the key never leaves the phone. Only code in your app can make that key and ask for the fingerprint or face before signing. #### What happens if the user has no trusted phone? Creating a request answers 404 not_found with "This user has no trusted phone". Fall back to an email code, a passkey or the authenticator app. #### What if the user picks the wrong number? A wrong number counts as denied. That is the point of number matching, it stops a user approving a request they did not start just to make the pushes stop. #### How many phones can a user trust? Five. Enrolling a sixth stops the oldest one being trusted. A phone that moves to another user stops being trusted for the first one. #### Is an expired request billed? No. Only approved and denied requests are counted. ## Set up passkeys on your website URL: https://signops.dev/guides/passkeys-web/ Your domain, the sign-in page origins, a registration token from your server, the browser prompt and the check before a session starts. A passkey lets a user sign in with the fingerprint, face or screen lock they already use to unlock their phone or laptop. It is saved by Google Password Manager, iCloud Keychain or the browser for **your** domain, and it cannot be typed into a fake site. This guide adds passkeys to a website that already has a sign-in. It takes about 30 minutes. How it fits together: your server asks SignOps for short-lived tokens, the browser script talks to the user’s device, and your server checks the result before starting its own session. Your login system keeps its users and sessions. ### 1. Enter your domain in the dashboard In the [SignOps dashboard](https://app.signops.dev), open **Passkeys**, **Setup**: 1. **Domain**: the domain users know, such as `kiranaexpress.in`. No `https://`, no `www`. This is fixed once anyone has a passkey. 2. **Name in the prompt**: your product name, such as `Kirana Express`. 3. **Page origins**: every address that shows your sign-in or “add a passkey” page, such as `https://kiranaexpress.in` and `https://app.kiranaexpress.in`. They must be on the domain or a subdomain. Other origins are refused. 4. **Allow synced passkeys**: leave on. Off allows only passkeys tied to one device, which users lose with the device. 5. Choose **Save and turn on**. For local development, make a second SignOps app with the domain `localhost` and the origin `http://localhost:3000`. ### 2. Let signed-in users add a passkey Offer it where users expect it: right after a sign-in (“Sign in faster next time with your fingerprint”) and on the account security page. Your server makes a registration token for the signed-in user: ```js // Your server app.post("/api/passkeys/token", requireUser, async (req, res) => { const { token } = await signops.passkeys.registrationToken({ user_id: req.user.id, // your own user id user_name: req.user.email, // what the user sees in their password manager }); res.json({ token }); }); ``` Your page passes it to the browser script: ```js import { configure, passkeys } from "https://api.signops.dev/web.js"; // or npm install @signops/web configure({ key: "pk_live_..." }); // your publishable key const { token } = await (await fetch("/api/passkeys/token", { method: "POST" })).json(); await passkeys.register({ token }); // The device's prompt appears; the passkey is saved for your domain. ``` ### 3. Sign in with a passkey Add `webauthn` to the email field’s `autocomplete`, so the browser offers saved passkeys as the user taps the field: ```html ``` ```js // On the sign-in page, at load const { signops_token } = await passkeys.signIn({ autofill: true }); await fetch("/api/passkeys/signin", { method: "POST", body: JSON.stringify({ signops_token }) }); ``` Add a **Sign in with a passkey** button that calls `passkeys.signIn()` without `autofill`, for browsers that do not offer autofill. ### 4. Check the token on your server The token proves nothing until your server confirms it. Never start a session from the browser’s word alone. ```js app.post("/api/passkeys/signin", async (req, res) => { const r = await signops.passkeys.verify(req.body.signops_token); if (!r.valid) return res.status(401).end(); startSession(res, r.user_id); // your own session, as after a password res.json({ ok: true }); }); ``` A token works once and only for a short time, so a copied token cannot be replayed. ### 5. Try it If you cannot reach your own site yet, the dashboard’s **Try it here** works on a test app with the domain `signops.dev` and the origin `https://app.signops.dev`. On your site: add a passkey, sign out, and sign in again by tapping the email field. Check what your browser supports with the [passkey checker](https://signops.dev/tools/passkey-check/). Then manage what you made: **Users** in the dashboard lists each user’s passkeys, and `DELETE /v1/passkeys/:id` removes one (for a “Remove this passkey” button on your security page). ### Next - A runnable Next.js app with these steps: [nextjs-passkeys on GitHub](https://github.com/signops-dev/signops-examples/tree/main/nextjs-passkeys) - [The same passkeys in your Android and iOS apps](https://signops.dev/guides/passkeys-apps/) - With Better Auth or Supabase: the [plugins](https://signops.dev/docs/#plugins) end the passkey sign-in in their own session - Why passkeys beat codes: [Passkeys vs OTP](https://signops.dev/blog/passkeys-vs-otp/) - How autofill works underneath: [passkey autofill](https://signops.dev/blog/passkey-autofill/); when the browser throws: [WebAuthn errors](https://signops.dev/blog/webauthn-errors/) ### Questions #### Which domain should I enter? The domain users know, without www, such as kiranaexpress.in. Passkeys made for it work on the domain and every subdomain (www, app, accounts). It cannot change once users have passkeys, because each passkey is tied to it. #### Can I test passkeys on localhost? Yes. Make a separate SignOps app for development with the domain localhost and an origin such as http://localhost:3000. Keep your real domain on the live app. #### Does SignOps show its own name to users? No. The browser's prompt shows your domain and the name you enter in the dashboard. There is no hosted login page. #### What if a user's device has no passkey support? Keep your current sign-in as the fallback. Passkeys sit next to it; they do not replace it until you decide they should. ## Set up passkeys in your Android and iOS apps URL: https://signops.dev/guides/passkeys-apps/ Find the SHA-256 of your signing certificate, the Team ID and bundle id, host assetlinks.json and apple-app-site-association, and add Associated Domains. Passkeys in an Android or iOS app are the same passkeys as on your website: made for your domain and checked by the same server calls. What the app adds is proof that it belongs to the domain, through a file you host on the domain. Do the [website guide](https://signops.dev/guides/passkeys-web/) first, or at least its step 1, so the dashboard knows your domain. The SignOps phone SDKs (Android, iOS, React Native, Flutter) make the passkey calls. They are not on Maven Central, Swift Package Manager, npm or pub.dev yet; the [API reference](https://signops.dev/docs/#passkeys) lists the calls they make. ### Android #### 1. Find your package name and certificate fingerprint - **Package name**: the `applicationId` in `app/build.gradle.kts`, such as `com.kiranaexpress.app`. - **SHA-256 of the signing certificate**, for each way the app is signed: - **Google Play**: Play Console, your app, **App integrity**, **App signing**, the SHA-256 under **App signing key certificate**. This is the one on users’ phones. - **Debug builds** from Android Studio: ```sh keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android ## or, in your project: ./gradlew signingReport ``` Copy the line that starts `SHA256:`, such as `14:6D:E9:83:C5:73:...:44:E5`. #### 2. Add the app in the dashboard **Passkeys**, **Setup**, **Android and iOS apps**: type the package name and the SHA-256, and choose **Add**. Add one row per certificate (Play and debug). The dashboard then shows an `assetlinks.json`. #### 3. Host assetlinks.json Put the file the dashboard shows at: ```plaintext https://kiranaexpress.in/.well-known/assetlinks.json ``` On the domain from step 1 of the website guide (not a subdomain), over https, as `application/json`, with no redirect. In most frameworks that means a `public/.well-known/assetlinks.json` file. Check it with Google’s [Statement List Generator and Tester](https://developers.google.com/digital-asset-links/tools/generator). #### 4. Call the SDK ```kotlin // minSdk 28. The registration token comes from your server, as for the web. val signops = SignOps(context, "pk_live_...") lifecycleScope.launch { signops.passkeys.register(this@MainActivity, registrationToken) // add a passkey val r = signops.passkeys.signIn(this@MainActivity) // sign in api.signIn(r.signopsToken) // your server checks it with POST /v1/passkeys/verify } ``` The error `no_passkey` means the user has none for your domain on this phone; offer your other sign-in. `cancelled` means they closed the prompt. ### iOS #### 1. Find your Team ID and bundle id - **Team ID**: [developer.apple.com/account](https://developer.apple.com/account), **Membership details**. - **Bundle id**: Xcode, your app target, **General**, **Bundle Identifier**. #### 2. Add the app in the dashboard **Passkeys**, **Setup**, **Android and iOS apps**: type the Team ID and the bundle id, and choose **Add**. The dashboard then shows an `apple-app-site-association` file. #### 3. Host apple-app-site-association ```plaintext https://kiranaexpress.in/.well-known/apple-app-site-association ``` The name has **no** `.json` extension, and it must still be served as `application/json`, over https, with no redirect. Static hosts usually need a header rule for that one path. Apple fetches the file through its own cache, so a change can take time to reach phones. #### 4. Add Associated Domains in Xcode Your app target, **Signing and Capabilities**, **+ Capability**, **Associated Domains**, then add: ```plaintext webcredentials:kiranaexpress.in ``` While developing, use `webcredentials:kiranaexpress.in?mode=developer` and turn on **Associated Domains Development** under **Settings**, **Developer** on the iPhone. The phone then reads the file from your site directly instead of Apple’s cache. Remove `?mode=developer` before you ship. #### 5. Call the SDK ```swift // iOS 16 and later let signops = SignOps(key: "pk_live_...") let made = try await signops.passkeys.register(token: registrationToken, anchor: view.window!) let r = try await signops.passkeys.signIn(anchor: view.window!, autofill: true) try await api.signIn(r.signopsToken) // your server checks it with POST /v1/passkeys/verify ``` With `autofill: true`, the passkey is offered in the keyboard on a text field with `textContentType = .username`. ### React Native and Flutter The same two steps (dashboard, then the hosted file and Associated Domains) apply. In code: ```tsx // React Native, @signops/react-native await signops.passkeys.register(registrationToken); const { signopsToken } = await signops.passkeys.signIn(); ``` ```dart // Flutter, signops await signops.passkeys.register(registrationToken); final signedIn = await signops.passkeys.signIn(); // signedIn.signopsToken to your server ``` Flutter’s `MainActivity` must extend `FlutterFragmentActivity`, and both need Android `minSdk 28` and iOS 16. ### Your server does not change The registration token and the check after sign-in are the same calls as for the website. One server endpoint serves the browser, the Android app and the iOS app. ### Questions #### Which SHA-256 do I add for an app on Google Play? The app signing key certificate from Play Console, App integrity, App signing. With Play App Signing, Google re-signs your app, so the fingerprint of your upload key is not the one on users' phones. Add your debug certificate as well while you develop. #### Do passkeys made on my website work in my app? Yes. Once the app is linked to your domain with assetlinks.json or apple-app-site-association, the same passkeys work in the browser and in the app, and the same server calls check them. #### Why does iOS not see my apple-app-site-association file? Apple fetches it through its own cache, not from the phone, so a new or changed file can take a while to reach devices. It must be at /.well-known/apple-app-site-association, served as application/json over https with no redirect. While developing, add ?mode=developer to the Associated Domains entry and turn on Associated Domains Development under Settings, Developer on the iPhone. #### Which versions are supported? Android 9 and later through Credential Manager, and iOS 16 and later through AuthenticationServices. ## Set up authenticator app codes URL: https://signops.dev/guides/authenticator/ A two-step settings page with a QR code, backup codes shown once, the code check at sign-in, a lost phone, the lock, and how to test it on your phone. Authenticator app codes are the six digits from Google Authenticator or a similar app, typed as a second step after the password. Nothing changes in your mobile app: the user scans a QR code from your settings page, and your server checks each code with one call. This guide builds that settings page, the sign-in step and the lost-phone path. It takes about an hour. ### How it fits together 1. **Turn on**: your server asks SignOps to start enrolment, your page shows the QR, the user scans it and types the first code, and your server gets 10 backup codes to show once. 2. **Sign in**: after the password, your page asks for a code, and your server checks it. 3. **Lost phone**: a backup code works in place of an app code. Your support team can remove the authenticator from the dashboard. Every call is from your server with the secret key. The examples use the Node library (`npm install @signops/node`); PHP and Python have the same calls, and the [API reference](https://signops.dev/docs/#totp) has cURL. ### 1. Turn on two-step sign-in On your account security page, add a **Turn on authenticator app** button. It calls your server: ```js app.post("/api/2fa/start", requireUser, async (req, res) => { const { qr_svg, secret } = await signops.totp.enroll({ user_id: req.user.id, // your own user id label: req.user.email, // shown in the authenticator app }); res.json({ qr_svg, secret }); }); ``` Show `qr_svg` on the page (it is an SVG, ready to put in the page), and under it the `secret` for users who type it in, in groups of four. Then ask for the first code: ```js app.post("/api/2fa/confirm", requireUser, async (req, res) => { const { backup_codes } = await signops.totp.confirm({ user_id: req.user.id, code: req.body.code }); res.json({ backup_codes }); // show them once, with Download and Copy buttons }); ``` A wrong first code answers 400 `invalid_code` and the user tries again with the next one; wrong first codes count toward the lock too. Until the first code is right, the authenticator is not on, and a start that is never confirmed is cleared away on its own. Show the 10 backup codes once, and say plainly: “Save these. Each one signs you in once if you lose your phone.” SignOps keeps only a keyed hash of each, so they cannot be shown again. ### 2. Ask for a code at sign-in After the password is right, check whether the user has an authenticator (store a flag in your own users table when `confirm` succeeds, or call `signops.totp.status(user_id)`). If they do, show a code field instead of signing them in: ```html ``` ```js app.post("/api/2fa/check", async (req, res) => { const userId = req.session.pendingUserId; // set after the password was right try { const r = await signops.totp.verify({ user_id: userId, code: req.body.code }); if (!r.valid) return res.status(401).json({ left: r.failures_before_lock }); if (r.method === "backup_code" && r.backup_codes_left <= 3) flash(req, "You have few backup codes left. Make new ones in settings."); startSession(res, userId); } catch (e) { if (e.code === "user_locked") { const lock = await signops.users.lock(userId); // locked_until, for "try again in 2 minutes" return res.status(429).json({ locked_until: lock.locked_until }); } throw e; } }); ``` The same call takes an app code or a backup code; `method` says which. Each code works once, so a code seen over someone’s shoulder cannot be used again. ### 3. The lock Five wrong codes in 15 minutes lock the user for one minute, then two, doubling up to an hour. While locked, verify answers 429 `user_locked` with `Retry-After`, and your webhook gets `user.locked`. A right code clears the count. Say so on the page (“Too many wrong codes. Try again in 2 minutes.”). If a real user is stuck, unlock them early with `signops.users.unlock(user_id)` or from the user’s page in the dashboard. ### 4. Settings the user will want | Button on your security page | Call | | --- | --- | | Make new backup codes | `signops.totp.backupCodes(user_id)`: the old ones stop working | | Turn off the authenticator | `signops.totp.remove(user_id)`, after asking for a current code | | Move to a new phone | Turn it off, then turn it on again with the new phone | ### 5. A lost phone with no backup codes Decide this before you need it. Common choices: the user proves who they are to your support team, which removes the authenticator under **Users** in the dashboard (or with `DELETE /v1/users/:user_id/totp`), or the user signs in with an email code plus a waiting period. The removal goes to your webhook as `totp.removed`. ### 6. Test it on your own phone Before writing code, try the whole flow in the dashboard: 1. Install Google Authenticator or Microsoft Authenticator on your phone. 2. In the [dashboard](https://app.signops.dev), open **Authenticator**, **Setup**, **Try it here**. Keep the user id `try-user-1` and choose **Start enrolment**. 3. In the authenticator app, tap **+**, **Scan a QR code**, and scan the screen. If the dashboard is open on the same phone, copy the key under the QR instead and choose **Enter a setup key**, **Time based**. 4. Type the current code and choose **Confirm**. You get 10 backup codes. 5. Type the next code and choose **Check code**: “Good code”. Type the same code again: it fails, because each code works once. Try a backup code, then five wrong codes to see the lock. Change the user id to start over, or remove it from the user’s page under **Users**. Every check counts toward the month’s authenticator checks, so test by hand rather than in a loop. ### Next - The second step without an app to install: [Passkeys](https://signops.dev/guides/passkeys-web/) or [email codes](https://signops.dev/guides/email-otp/) - What each answer and error looks like: [Authenticator in the API reference](https://signops.dev/docs/#totp) - How codes are made, and why correct ones get rejected: [TOTP code invalid](https://signops.dev/blog/totp-code-invalid/); test codes in the browser with the [TOTP code checker](https://signops.dev/tools/totp/) ### Questions #### Which authenticator apps work? Any app that reads TOTP QR codes, including Google Authenticator, Microsoft Authenticator, Authy, 1Password, Bitwarden and the iPhone's own Passwords app. Codes are 6 digits, every 30 seconds, SHA-1, as RFC 6238 sets out. #### What name does the user see in their authenticator app? Your app's name in SignOps and the label you send, such as Kirana Express (ananya@example.in). Pass issuer to show a different name. SignOps never appears. #### The user's codes are always wrong. Why? Almost always the phone's clock. SignOps accepts the code 30 seconds early or late. Ask the user to turn on automatic date and time, or use a backup code. #### Can a user have two authenticator apps? One authenticator per user. They can scan the same QR into two apps at enrolment, or keep the backup codes for when the phone is lost. ## Set up email OTP and magic links URL: https://signops.dev/guides/email-otp/ Test with your own address first, then add your domain and its DNS records, send and check codes, and switch to sign-in links if you prefer. Email OTP sends a 6-digit code from your own domain and checks it for you: the code is made, hashed, emailed and expired by SignOps. Use it to confirm an email at sign-up, for password resets, and as a sign-in that needs no password. This guide gets you from test codes in your own inbox to live codes from your domain. Allow an hour, most of it waiting for DNS. ### 1. Test with your own address first You can build the whole flow before touching DNS: 1. In the [dashboard](https://app.signops.dev), open **Email OTP**, **Settings**, and add your own email under **Test addresses**. 2. Use your **test** key (`sk_test_...`). Test codes go from `codes@auth.signops.dev` to your test addresses only, up to 100 a day (50 on Free), and are not billed. ### 2. Send a code ```js // Your server: the user typed their email and asked for a code const { otp_id } = await signops.otp.send({ email: "ananya@example.in", app_name: "Kirana Express", // shown in the email, up to 40 characters ip: req.ip, // for the per-IP limit // language: "hi" for Hindi }); // Keep otp_id with the sign-in attempt, such as in the session, not in the page ``` The email holds the code, your `app_name` and your brand colour, and no links, so it cannot be used to phish. ### 3. Check the code ```js const { valid } = await signops.otp.verify({ otp_id, code: req.body.code }); if (valid) startSession(res, user.id); // or mark the email confirmed ``` A right code answers `valid: true` once, then it is gone. A wrong one answers `valid: false` with `tries_left`. After five wrong tries the code is dead (`too_many_tries`); after 10 minutes it expires (`expired`). Either way, offer **Send a new code**. Use `inputmode="numeric"` and `autocomplete="one-time-code"` on the field, so phones offer the code from the email. ### 4. Add your domain for live codes Live codes go from your own domain. In **Email OTP**, **Domain**: 1. Type your main domain (`kiranaexpress.in`, not a subdomain), the **from name** (`Kirana Express`) and the address before the `@` (`no-reply`). Codes will come from `Kirana Express `. 2. The page shows the DNS records. Add each at your DNS host: | Type | Name | Value | What it is for | | --- | --- | --- | --- | | CNAME (three of them) | `._domainkey.kiranaexpress.in` | `.dkim.amazonses.com` | Signs every code with your domain | | MX, priority 10 | `auth.kiranaexpress.in` | `feedback-smtp.us-east-2.amazonses.com` | Undelivered codes come back here | | TXT | `auth.kiranaexpress.in` | `v=spf1 include:amazonses.com ~all` | SPF for the bounce domain | | TXT, only if you have none | `_dmarc.kiranaexpress.in` | `v=DMARC1; p=none;` | Gmail and Yahoo expect a DMARC record | Copy the exact names and values from the dashboard: the `` parts are your own. - **Cloudflare**: SignOps can add every record for you. Press **Open Cloudflare** on the page: a token form opens already filled in (DNS edit and zone read). Press **Continue to summary**, then **Create Token**, and paste the token back. SignOps uses it for that one request and does not keep it; delete it at Cloudflare afterwards. Records it adds are DNS only, and an existing DMARC record is left as it is. To add them by hand instead, set each to **DNS only** (grey cloud). - **GoDaddy, Hostinger, Namecheap, Route 53 and others**: the page names your DNS host, links to its DNS page, and shows each name the way that host wants it (only the part before your domain, such as `auth`, where the host adds the domain itself). - **Any other host**: the page lists your domain’s name servers, so you know where to sign in. - **Someone else runs your DNS?** Copy the link under **Someone else runs your DNS**. It opens a page with only these records and the ways to add them, needs no SignOps account, and lasts 7 days. The page checks the records every 30 seconds while it is open and ticks each one as it appears. The page shows **Waiting for DNS**, then **Verified**. From then on your live key sends from your domain. Live keys work once your SignOps account is approved. ### 5. Keep your sending healthy Each app starts at 1,000 codes a day and moves up as it sends cleanly (10,000 after 7 days and 1,000 clean codes, then 1 lakh). Three numbers decide that, and they are on the **Email OTP** page: - **Bounces**: check the address format before sending, and never send to addresses you bought or guessed. - **Spam complaints**: send codes only when the user asked for one. - **Codes typed back**: a code nobody types back is often a bot filling your form. Put a captcha or a rate limit on the form if that number drops. Past the warning line your webhook gets `email.warning`; past the pause line, codes stop for 24 hours (`sending_paused`). An address that bounces or complains is put on your do-not-send list and answers `address_suppressed`; remove it there if it was a mistake. Limits per address and IP: 5 codes an hour to one address, 30 seconds apart, and 20 an hour from one IP when you pass `ip`. Change them under **Email OTP**, **Settings**. ### 6. Magic links instead of codes A link signs the user in with one tap. It uses the same domain, limits and price: ```js await signops.magicLinks.send({ email: "ananya@example.in", redirect_url: "https://kiranaexpress.in/auth/email", // your page ip: req.ip, }); // /auth/email?signops_token=... : your page shows a "Continue" button that POSTs the token const { valid, email } = await signops.magicLinks.verify({ token: req.body.signops_token }); ``` `redirect_url` must be https on your verified domain or a subdomain, or a host you list under **Email OTP**, **Settings**, **Magic links** (and `http://localhost` with a test key). Check the token from a button press, not when the page loads: some mail scanners open links before the user does. ### Using a login system? - [Better Auth email OTP](https://signops.dev/blog/better-auth-email-otp/) - [Firebase Auth with email OTP](https://signops.dev/blog/firebase-auth-email-otp/) - [Supabase email limits](https://signops.dev/blog/supabase-email-rate-limit/) ### Questions #### Why do codes have to come from my own domain? Inboxes trust mail signed by the domain the user knows. Codes from your domain, signed with its DKIM key, land in the inbox and look like yours. It also keeps one customer's sending from affecting another's. #### Will adding the DNS records change my existing email? No. The records go on names Amazon SES gives (ending in ._domainkey) and on auth. under your domain. Your MX records, Google Workspace or Zoho mail are not touched. Add the DMARC record only if your domain has none. #### Can I send from a subdomain such as mail.example.com? Use your main domain. SignOps adds auth. under it for bounces, and users trust the domain they know. #### How long does verification take? Most DNS hosts take a few minutes. SignOps checks every 10 minutes and shows Verified when all records are found. If they are not found in 72 hours, it shows Records not found; check them and add the domain again. #### Code or magic link? A code works when the user reads email on another device, such as a laptop sign-in with mail on the phone. A link is one tap when they read email on the same device. Many apps send a code and offer a link as well. ## A OneSignal alternative, now that free mobile push stops at 1,000 users URL: https://signops.dev/onesignal-alternative/ OneSignal's free plan now stops mobile push at 1,000 monthly users. What it costs to stay, the free alternatives, and how SignOps compares in rupees. From **1 October 2026**, OneSignal’s free plan stops mobile push above **1,000 monthly active users** (new accounts since 1 September). Web push and email keep working, but an app with 1,001 users now pays or loses push. If you are deciding whether to upgrade or move, here are the numbers. ### What staying on OneSignal costs The Growth plan is **$19 a month plus $0.012 per monthly active user** above 1,000. | Monthly users | OneSignal Growth | SignOps | | --- | --- | --- | | 1,000 | Free | Free | | 5,000 | About Rs 6,400 | Rs 499 | | 20,000 | About Rs 23,500 | Rs 2,999 | | 50,000 | About Rs 57,700 | Rs 7,499 | OneSignal at Rs 95 to the dollar, before GST. SignOps Starter is Rs 499 for 5,000 devices, then Rs 0.20 a device; Pro is Rs 1,499 for 10,000, then Rs 0.15, which is less from about 10,000 devices. SignOps is free up to 1,000 devices. The SignOps column counts devices; one user with a phone and a laptop counts twice. ### The alternatives | Option | Free | Paid | What you build yourself | | --- | --- | --- | --- | | **FCM directly** | Unlimited | Free | Token storage per user, targeting, scheduling, cleanup, a dashboard | | **Expo push** | Unlimited | Free | The same, and it only works for Expo apps | | **Pusher Beams** | 1,000 devices | $29 a month for 10,000 | Segments and a composer | | **Truepush** | Free | None | Mainly web push for publishers | | **[SignOps](https://signops.dev/push/)** | 1,000 devices | Rs 499 a month for 5,000 | Nothing for push | ### Where OneSignal is still the better choice - You use **journeys, in-app messages and A/B tests** heavily. SignOps has topics, scheduling, templates, caps and a composer, not journeys. - Your marketing team already lives in OneSignal’s dashboard. ### Where SignOps fits - You want push without a per-user dollar bill: **Rs 499 a month for 5,000 devices**, with a GST invoice. - You also need **login approvals, passkeys, authenticator codes or email OTP**. SignOps puts them all on one API key and one user id, so the device that gets your pushes also approves logins. - You want your **own Firebase and Apple keys**, so tokens stay in your project and you can leave any time. ### Moving from OneSignal 1. Export subscribers from OneSignal with their **external user id** and push token. 2. Upload your Firebase service account and APNs key to SignOps once. 3. Import the tokens with `POST /v1/devices/import` or a CSV in the dashboard. 4. Change your server’s send call to `POST /v1/push` with your user ids. Users keep getting pushes without an app update, as long as your app registers with Firebase, which OneSignal’s SDK already does on Android. Sources: [OneSignal: what’s changing on the free plan](https://onesignal.com/blog/whats-changing-on-onesignals-free-plan/), [OneSignal pricing guide (Sequenzy)](https://www.sequenzy.com/pricing/onesignal), [Pusher Beams pricing](https://apiscout.dev/blog/best-push-notification-apis-2026). ### Questions #### What changed on OneSignal's free plan? From 1 September 2026 for new accounts and 1 October 2026 for existing ones, free mobile push and in-app messages stop above 1,000 monthly active users. Web push and email stay on the free plan's existing limits. Above 1,000, accounts get 24 hours to upgrade before mobile push pauses. #### What does OneSignal cost after the free plan? The Growth plan is $19 a month plus $0.012 per monthly active user above 1,000. For 5,000 users that is about $67, or about Rs 6,400, a month. #### Is there a free OneSignal alternative? Firebase Cloud Messaging and Expo's push service are free with no user limit, but you build the targeting, scheduling and dashboard yourself. SignOps is free for 1,000 devices and Rs 499 a month for 5,000. #### Can I move my OneSignal subscribers without an app update? Yes, if your app already registers with Firebase. Export subscribers with their external user ids and push tokens, import them to SignOps by CSV or the API, and switch your server's send calls. ## OTP service providers in India, compared on price and setup URL: https://signops.dev/otp-service-provider-india/ SMS, WhatsApp, email and push OTP providers in India with prices per code, DLT rules, and the options that need no DLT registration. “OTP provider” covers four different channels in 2026: SMS, WhatsApp, email and in-app. They differ by an order of magnitude in price and by days in setup. Here is each one compared on what an app in India pays and what it has to register. ### By channel | Channel | Price each in India | Setup | Phishable | | --- | --- | --- | --- | | SMS OTP | About Rs 0.13 to Rs 0.25 from Indian providers | DLT entity, header and templates | Yes | | WhatsApp authentication | About Rs 0.115 plus GST, plus provider fees | WhatsApp Business account and template approval | Yes | | Email OTP | A few paise; Rs 0.025 on SignOps | A verified sending domain | Yes | | Push approval | Rs 0.02 on SignOps past the daily allowance, about Rs 4.75 on Twilio | An SDK in your app | No, with number matching | | Passkeys | Nothing per sign-in | WebAuthn on your server | No | ### SMS OTP providers | Provider | SMS OTP price | Notes | | --- | --- | --- | | MSG91 | Rs 0.16 to Rs 0.25 by pack size, Rs 0.13 by quote | Indian, SMS, WhatsApp and email | | Gupshup | About Rs 0.17 | Indian, strong on WhatsApp | | Kaleyra | About Rs 0.18 | Enterprise focus | | Twilio | $0.0832 list price, about Rs 7.90 | Global, billed in dollars | All need DLT registration first. See [SMS OTP cost in India and DLT](https://signops.dev/blog/sms-otp-cost-india/). For your monthly cost by SMS, WhatsApp and email, use the [OTP cost calculator](https://signops.dev/tools/otp-cost-calculator/). ### Without DLT If you want to start sending this week without DLT paperwork, use channels that do not go over SMS: - **[Email OTP](https://signops.dev/email-otp/)**: two calls, send and verify. SignOps gives 500 codes to try, then 2,000 a month on Starter at Rs 499, sent from your own domain. - **[Push approval](https://signops.dev/approve/)**: for users already signed in on their phone. 100 a day free. - **[Passkeys](https://signops.dev/passkeys/)**: 1,000 sign-ins a month free, 50,000 on Starter. ### How to choose 1. **Must you prove a phone number?** Then you need SMS (or WhatsApp) once, at signup. 2. **For every login after that**, move to email codes, push approval or passkeys. Each is cheaper or safer than SMS, and none needs DLT. 3. **Count resends.** Real cost per login is the price times about 1.2 for most SMS routes. ### Where SignOps fits SignOps is not an SMS provider. It covers the logins after signup: email OTP, push approval, passkeys and authenticator app codes on one API key and one user id, plus push notifications, with a GST invoice. Keep your SMS provider for the first verification. Sources: [Message Central: SMS OTP pricing in India](https://www.messagecentral.com/blog/sms-otp-pricing-india), [WhatsApp Business API pricing](https://www.authgear.com/post/whatsapp-api-pricing), [Twilio Verify pricing](https://twilio.com/verify/pricing). ### Questions #### Which is the cheapest OTP service provider in India? For SMS, MSG91 lists Rs 0.16 to Rs 0.25 a message by pack size, and Rs 0.13 by quote. Email OTP is cheaper than any SMS, at Rs 0.025 a code on SignOps, and passkeys cost nothing per sign-in. #### Can I get an OTP API without DLT? For SMS in India, no. Every OTP SMS needs a DLT-registered entity, header and template. Email OTP, WhatsApp authentication messages, push approval and passkeys need no DLT. #### Is there a free OTP API? Most providers give trial credit rather than a free plan. SignOps gives 500 email codes to try, 100 push approvals and 1,000 passkey sign-ins a month free, with no card until you pass a free amount. ## An MSG91 alternative for apps that want more than SMS URL: https://signops.dev/msg91-alternative/ What MSG91 charges for SMS and email OTP, where it is the right choice, and when SignOps push, approvals and passkeys cost less. MSG91 is one of India’s largest messaging providers: SMS, WhatsApp, email and voice, priced in rupees. If you are looking at alternatives, it is usually for one of two reasons: the SMS OTP bill has grown, or you need things MSG91 does not do, like push notifications or approving logins on a phone. ### What MSG91 charges | | MSG91 | | --- | --- | | SMS OTP | Rs 0.16 to Rs 0.25 a message by pack size, Rs 0.13 by quote | | Email | Free for 5,000 a month; Rs 2,000 a month plan, then Rs 0.02 an email | | WhatsApp | Meta’s rate plus MSG91’s fee | | Billing | Rupees, prices before GST | ### MSG91 and SignOps side by side | | MSG91 | SignOps | | --- | --- | --- | | SMS OTP | Yes | No | | WhatsApp OTP | Yes | No | | Email OTP | Yes | Yes, Rs 0.025 a code, 2,000 a month on Starter, from your own domain | | Push approval for logins | No | Yes, 100 a month free, 100 a day on Starter, then Rs 0.02 | | Passkeys | No | Yes, 1,000 sign-ins a month free, 50,000 on Starter | | Push notifications | No | Yes, 1,000 devices free, Rs 499 for 5,000 | | DLT needed | For SMS | No | ### When MSG91 is the right choice - You need **SMS or WhatsApp** to prove phone numbers. - You send **marketing SMS or WhatsApp** campaigns. ### When SignOps is the better fit - **Logins after signup.** Moving returning users from SMS OTP to [push approval](https://signops.dev/approve/), [passkeys](https://signops.dev/passkeys/) or [email codes](https://signops.dev/email-otp/) cuts the SMS bill and removes the code a fraud caller can ask for. - **Push notifications** to Android, iOS and the web on the same user id. - **No DLT paperwork** to start. ### Using both 1. **Signup:** SMS OTP through MSG91 to prove the phone number. 2. **Login on the same phone:** a passkey, no code at all. 3. **Login on a new device:** a push approval to the phone they signed up on. 4. **No trusted phone answers:** an email code. The SMS bill drops to one message per user instead of one per login. Sources: [MSG91 email pricing](https://msg91.com/in/pricing/email), [Message Central: SMS OTP pricing in India](https://www.messagecentral.com/blog/sms-otp-pricing-india). ### Questions #### What does MSG91 charge for OTP? SMS OTP is Rs 0.25 a message for small packs, down to Rs 0.16 at high volume and Rs 0.13 by quote, plus GST (MSG91's price page, 21 October 2026). Its email plans start free for 5,000 emails a month, then Rs 2,000 a month with extra emails at Rs 0.02. #### Does SignOps send SMS? No. SignOps covers email OTP, push approval, passkeys and push notifications. Keep an SMS provider such as MSG91 for proving phone numbers at signup. #### Can I use MSG91 and SignOps together? Yes. A common setup is SMS through MSG91 once at signup, then SignOps push approval, passkeys or email codes for logins after that. ## Twilio Verify pricing in India, and an alternative URL: https://signops.dev/twilio-verify-alternative/ Twilio Verify is $0.05 a verification (about Rs 4.75), plus about Rs 7.90 an SMS to India. What 1 lakh sign-ins cost, and SignOps in rupees. Twilio Verify is the default for many teams because it is one API for SMS, voice, email, WhatsApp, TOTP and push. In India its pricing adds up fast: a fee per verification on top of a fee per message, billed in dollars. ### What Twilio Verify costs | | Twilio Verify | | --- | --- | | Per verification | $0.05, about Rs 4.75 | | Push verification | $0.05, no extra channel fee | | Authenticator (TOTP) check | $0.05, no extra channel fee | | Volume pricing | By quote, usually from about 1 lakh a month | | SMS to India | Plus $0.0832 a message, about Rs 7.90 (list price) | | Billing | US dollars | ### One lakh verifications a month | Method | Twilio | SignOps | | --- | --- | --- | | Push approval | About Rs 4,75,000 | Rs 2,439 (Starter, 100 a day included, then Rs 0.02 each) | | Email OTP | About Rs 4,75,000 plus email | Rs 2,949 (Starter plus Rs 0.025 each past 2,000) | | Authenticator (TOTP) check | About Rs 4,75,000 | Rs 499 (Starter includes 1 lakh checks) | Before GST, at Rs 95 to the dollar, at Twilio’s list price; a volume quote will be lower. ### Feature by feature | | Twilio Verify | SignOps | | --- | --- | --- | | SMS and voice OTP | Yes | No | | WhatsApp OTP | Yes | No | | Email OTP | Yes | Yes | | Push approval with number matching | Push, number matching up to you | Yes, always on | | Authenticator app codes (TOTP) | Yes | Yes, with backup codes | | Passkeys | Verify Passkeys, in private beta | Yes, same account | | Push notifications | Separate products | Yes, same user id | | Free tier | Trial credit | 100 approvals, 1,000 passkey sign-ins and 10,000 authenticator checks a month, 500 email codes | ### When Twilio is the right choice - You need **SMS and voice** worldwide from one provider. - You already run on Twilio and the bill is not the issue. ### When SignOps fits - Your users are mostly in India and you want **a GST invoice in your company’s name**. - You want **push approval at Rs 0.02 instead of Rs 4.75**, with number matching and a signed challenge built in. - You want push notifications, approvals, passkeys and email codes on **one user id**. See [how push authentication and number matching work](https://signops.dev/blog/push-authentication-number-matching/). Sources: [Twilio Verify pricing](https://www.twilio.com/en-us/user-authentication-identity/pricing/verify), [Twilio Verify Passkeys](https://www.twilio.com/docs/verify/passkeys), [Message Central: Twilio Verify alternatives in India](https://www.messagecentral.com/blog/twilio-verify-alternative-india). ### Questions #### How much does Twilio Verify cost in India? Twilio Verify charges $0.05 per successful verification, about Rs 4.75, plus a channel fee for SMS. Twilio's SMS to India lists at $0.0832 a message, about Rs 7.90. Verify Push is $0.05 a verification with no extra channel fee. #### What is a cheaper alternative to Twilio Verify Push? SignOps Starter includes 100 approvals a day and Pro 300; past that an approval is Rs 0.02, about 240 times less than Verify Push. The free plan has 100 a month. #### How much does Twilio Verify cost for 1 lakh verifications a month? At the list price of $0.05 each, about Rs 4,75,000 a month before GST, plus SMS fees if the code goes by SMS. Twilio offers volume pricing by quote at that size. #### Does SignOps support SMS like Twilio Verify? No. SignOps covers push approval, passkeys, authenticator app codes (TOTP) and email OTP. For SMS in India, use a DLT-registered provider. ## Free push notification services for Android, iOS and the web URL: https://signops.dev/free-push-notification-service/ Every free push notification option in 2026, what the free tier covers, and what you still have to build: FCM, Expo, OneSignal, Pusher, Truepush and SignOps. Sending a push is free: Google’s FCM and Apple’s APNs charge nothing per message. What the free tiers differ on is everything around sending: storing tokens per user, topics, scheduling, a dashboard and logs. Here is every free option in 2026 and where each one stops. ### The free options | Service | Free tier | Platforms | What you still build | | --- | --- | --- | --- | | **Firebase Cloud Messaging** | Unlimited | Android, iOS, web | Token storage, targeting, scheduling, cleanup, dashboard | | **Expo push** | Unlimited | Android, iOS (Expo apps) | Targeting, scheduling, dashboard | | **OneSignal** | Mobile push up to 1,000 users; web push and email on free limits | Android, iOS, web | Nothing for basic push | | **Pusher Beams** | 1,000 devices | Android, iOS, web | Segments, composer | | **Truepush** | Free | Mainly web | Mobile app support is limited | | **[SignOps](https://signops.dev/push/)** | 1,000 devices | Android, iOS, web | Nothing for push | ### Past the free tier | Users or devices | OneSignal | Pusher Beams | SignOps | | --- | --- | --- | --- | | 5,000 | About Rs 6,400 | Rs 2,760 ($29, for 10,000) | Rs 499 | | 50,000 | About Rs 57,700 | Rs 9,400 ($99) | Rs 7,499 | Before GST, at Rs 95 to the dollar. ### How to choose - **One app, a backend, a developer with time:** use [FCM directly](https://signops.dev/blog/send-push-notification-node-js/). It is free and you control everything. - **An Expo app with simple sends:** Expo’s push service. - **Campaigns, journeys and in-app messages:** OneSignal. - **Push plus sign-in on the same user id:** SignOps, free for 1,000 devices, with push approvals, passkeys, authenticator codes and email OTP on the same account. ### One thing no free tier fixes On Xiaomi, Oppo, Vivo and Realme phones, battery savers stop apps and drop pushes on every service. Read [why pushes go missing on these phones](https://signops.dev/blog/push-notifications-xiaomi-oppo-vivo/) before you blame your provider. Sources: [Firebase pricing](https://firebase.google.com/pricing), [OneSignal free plan change](https://onesignal.com/blog/whats-changing-on-onesignals-free-plan/), [Pusher Beams pricing](https://apiscout.dev/blog/best-push-notification-apis-2026), [Expo push pricing](https://www.courier.com/integrations/compare/expo-vs-firebase-fcm), [Truepush](https://www.capterra.ca/software/186794/truepush). ### Questions #### What is the best free push notification service? For sending only, Firebase Cloud Messaging is free with no limit. For a service that also stores tokens per user, targets topics and schedules sends, OneSignal, Pusher Beams and SignOps are free for 1,000 users or devices. #### Is there a free push notification service for both Android and iOS? Yes. FCM sends to Android and, with your APNs key, to iOS, free. Expo's push service is free for both on Expo apps. SignOps is free for 1,000 devices across Android, iOS and the web. #### Can I send push notifications without Firebase? On iOS, yes, straight through APNs. On Android with Google Play services, FCM is the delivery path every service uses underneath. Phones without Google services, such as recent Huawei models, need the maker's own push service. ## Migrating from the FCM legacy API to HTTP v1 URL: https://signops.dev/blog/fcm-http-v1-migration/ The FCM legacy API is shut down. How to move to HTTP v1: service account tokens, the new payload, sending to many tokens, and Node, PHP and cURL examples. If your server still posts to `https://fcm.googleapis.com/fcm/send` with a server key, it has stopped delivering. Firebase deprecated the legacy HTTP and XMPP APIs on **20 June 2023** and began shutting them down on **22 July 2024**. The replacement is the **HTTP v1 API**. Your app does not change. Your server does, in three places: how it authenticates, where it sends, and what the payload looks like. ### What changes between legacy and v1 | | Legacy API | HTTP v1 | | --- | --- | --- | | Endpoint | `POST /fcm/send` | `POST /v1/projects/PROJECT_ID/messages:send` | | Authentication | `Authorization: key=SERVER_KEY`, never expires | `Authorization: Bearer ACCESS_TOKEN`, from a service account, lasts about an hour | | Many devices | `registration_ids` with up to 1,000 tokens | One target per request: a token, a topic or a condition | | Platform options | Mixed into one object | Separate `android`, `apns` and `webpush` blocks | | Data values | Any JSON | Strings only | ### Step 1: get a service account key In the Firebase console open **Project settings, Service accounts** and generate a new private key. Keep the JSON file out of your repo and your app. It is a password to every device of your project. ### Step 2: turn the key into an access token The token is an OAuth 2.0 bearer token with the scope `https://www.googleapis.com/auth/firebase.messaging`. Libraries fetch and refresh it for you. ```js // Node: npm install google-auth-library import { GoogleAuth } from "google-auth-library"; const auth = new GoogleAuth({ keyFile: "service-account.json", scopes: ["https://www.googleapis.com/auth/firebase.messaging"], }); const token = await auth.getAccessToken(); ``` ### Step 3: send with the v1 payload ```bash curl -X POST \ "https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "message": { "token": "DEVICE_TOKEN", "notification": { "title": "Order shipped", "body": "Arriving today" }, "data": { "order_id": "88213" }, "android": { "priority": "high" }, "apns": { "headers": { "apns-priority": "10" } } } }' ``` Two things trip people up here. Every value in `data` must be a string, so send `"88213"`, not `88213`. And `priority` moved into the `android` block. #### The same in PHP ```php // composer require google/auth guzzlehttp/guzzle use Google\Auth\Credentials\ServiceAccountCredentials; $creds = new ServiceAccountCredentials( 'https://www.googleapis.com/auth/firebase.messaging', json_decode(file_get_contents('service-account.json'), true) ); $token = $creds->fetchAuthToken()['access_token']; $client = new GuzzleHttp\Client(); $client->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", [ 'headers' => ['Authorization' => "Bearer {$token}"], 'json' => ['message' => [ 'token' => $deviceToken, 'notification' => ['title' => 'Order shipped', 'body' => 'Arriving today'], ]], ]); ``` ### Step 4: replace registration_ids HTTP v1 has no list of tokens. You have two choices: - **One request per token.** The Firebase Admin SDK does this in parallel with `sendEach` and `sendEachForMulticast`. The older `sendAll` and `sendMulticast` used a batch endpoint that was retired with the legacy API. - **Topics.** Subscribe devices to a topic once and send one message to `"topic": "offers"`. FCM fans it out. ```js // npm install firebase-admin import { initializeApp, cert } from "firebase-admin/app"; import { getMessaging } from "firebase-admin/messaging"; initializeApp({ credential: cert("service-account.json") }); const res = await getMessaging().sendEachForMulticast({ tokens: deviceTokens, // up to 500 per call notification: { title: "Order shipped", body: "Arriving today" }, }); console.log(res.successCount, "sent,", res.failureCount, "failed"); ``` ### Step 5: handle the new errors v1 errors are clearer than legacy ones. The two you will see most: - **`UNREGISTERED`** (404): the token is dead, usually because the app was uninstalled. Delete it from your database. - **`INVALID_ARGUMENT`** (400): a malformed token or payload. A number inside `data` is the usual cause. Keep the cleanup automatic. A list of dead tokens slows every send and inflates every count. Every other code, and which ones to retry, is in [FCM error codes](https://signops.dev/blog/fcm-error-codes/). Chrome’s web push also goes through FCM. The [web push tester](https://signops.dev/tools/web-push-test/) sends a real push to your browser and shows the status code the push service returned. ### HTTP v1 quotas and limits The v1 API has quotas per project, per minute, which the legacy API did not: | Limit | Default | Over it | | --- | --- | --- | | Messages sent, per Firebase project | 600,000 a minute | `429 RESOURCE_EXHAUSTED` until the quota refills | | Messages to one Android device | 240 a minute, 5,000 an hour | The extra messages are throttled | | Topic subscribe and unsubscribe calls, per project | 3,000 a second | `429`; retry with exponential backoff | | Topic sends running at once, per project | 1,000 | Wait for earlier topic sends to finish | | Topics one app install can join | 2,000 | Further subscriptions fail | Three things to know about the main quota: - **It counts messages, not requests.** Every token is one message, whether you send it alone or with `sendEachForMulticast`. - **The minutes are not aligned to the clock.** Do not wait for :00. Back off on `429` and use the `Retry-After` header if it is there. - **600,000 a minute covers most apps.** It is 10,000 messages a second, and Google can raise it for projects that need more. FCM itself is free at any volume; the quota is a rate, not a bill. See [is Firebase push notification free](https://signops.dev/blog/firebase-push-notification-pricing/) for what does cost money. ### If you would rather not run this All of the above is what [SignOps Push](https://signops.dev/push/) does: it holds your service account and APNs key, sends through HTTP v1 to every device a user has, removes dead tokens and tells your server by webhook. You call one endpoint with your own user id. It is free for 1,000 devices. Sources: [Firebase: migrate from legacy FCM APIs to HTTP v1](https://firebase.google.com/docs/cloud-messaging/migrate-v1), [Firebase: FCM throttling and quotas](https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas). ### Questions #### Is the FCM legacy API still working? No. Firebase deprecated sending through the legacy HTTP and XMPP APIs on 20 June 2023 and began shutting them down on 22 July 2024. Requests to fcm.googleapis.com/fcm/send with a server key no longer deliver. Use the HTTP v1 API. #### Can HTTP v1 send to multiple tokens in one request? No. One v1 request carries one message for one target, which can be a token, a topic or a condition. To reach many devices, send one request per token (the Admin SDK does this for you with sendEach and sendEachForMulticast) or subscribe the devices to a topic. #### Where do I get the access token for Postman? From a service account. Download its JSON key from the Firebase console under Project settings, Service accounts, then exchange it for a short-lived OAuth 2.0 token with the firebase.messaging scope, for example with the google-auth-library in Node or gcloud. The token lasts about an hour. #### What is the FCM HTTP v1 quota? 600,000 messages a minute per Firebase project by default. It counts messages, not requests, and the minutes are not aligned to the clock. Over it, FCM answers 429 until the quota refills. Android also caps one device at 240 messages a minute and 5,000 an hour, and topic subscribe and unsubscribe calls at 3,000 a second per project. #### Do device tokens change when I migrate? No. Tokens your app already has keep working with HTTP v1. Only the server side changes; the endpoint, the authentication and the payload shape. ## FCM says success but the notification never arrives URL: https://signops.dev/blog/fcm-notification-not-received/ Why Android does not show FCM notifications when the app is closed or killed: message types, priority, Doze, permissions, stale tokens and OEM battery savers. FCM answered `200` with a message id, and the phone stayed silent. That response only means Google accepted the message. Between FCM and the screen there are six places it can stop. Work through them in this order; the first two explain most cases. ### 1. A data-only message while the app is killed FCM has two kinds of message: - **Notification messages** have a `notification` block. When your app is in the background or closed, Android’s system tray shows them without running any of your code. - **Data messages** have only a `data` block. Android always hands them to your app’s `onMessageReceived`. If your app is not running and cannot be started, nothing is shown. If your app builds the notification itself from `data`, it works while the app is open and fails when it is closed. The fix is to include a `notification` block for anything the user must see: ```json { "message": { "token": "DEVICE_TOKEN", "notification": { "title": "Sign in from Chrome?", "body": "Tap to approve" }, "data": { "challenge_id": "apr_51mz0c" }, "android": { "priority": "high", "notification": { "channel_id": "logins" } } } } ``` ### 2. The notification permission on Android 13 and later From Android 13 (API 33), apps must ask for `POST_NOTIFICATIONS` at run time. Without it, FCM still delivers and returns success, and Android silently drops the notification. Ask at a moment the user understands, like right after they turn on order updates, not on the first screen. ### 3. Priority and Doze When a phone sits idle, Android’s Doze mode holds normal-priority messages until a maintenance window, which can be many minutes later. High-priority messages wake the device. Set `android.priority` to `high` for messages the user is waiting for. Do not make everything high priority: Android may lower the priority of apps whose high-priority messages do not result in a visible notification. ### 4. A channel the user muted On Android 8 and later every notification goes through a channel. If you send to a channel id that does not exist, or the user turned the channel off, nothing appears. Create your channels at app start and keep them separate, for example `logins` and `offers`, so users can mute offers without missing logins. ### 5. A stale or wrong token Tokens change when the app is reinstalled, its data is cleared, or it is restored on a new phone. A token from your development build will not work with your production Firebase project either. Send to a dead token and v1 returns `UNREGISTERED`; delete it (the other codes are in [FCM error codes](https://signops.dev/blog/fcm-error-codes/)). Refresh the token in your app with `onNewToken` and send the new one to your server every time. ### 6. The phone maker’s battery saver Xiaomi, Redmi, Poco, Oppo, Realme, Vivo and OnePlus phones add their own battery management on top of Android. Swiping an app away from recents can act like a force stop, and Android does not deliver messages to a force-stopped app until the user opens it again. This is the most common reason pushes fail in India, where these brands sell most of the phones. The fixes are in [push notifications on Xiaomi, Oppo, Vivo and Realme](https://signops.dev/blog/push-notifications-xiaomi-oppo-vivo/). ### A quick test that separates the causes 1. Send a **notification message** to one token from the Firebase console’s test send, with the app in the background, not swiped away. 2. If that shows, your message type or channel is the problem. 3. If it does not, check the permission, then try the same test on a Pixel or Samsung. If it shows there and not on a Xiaomi, it is the battery saver. For web push, the [web push tester](https://signops.dev/tools/web-push-test/) sends a real push to your browser and shows what the push service answered, so you can tell a sending problem from a browser setting. ### What SignOps does about it [SignOps Push](https://signops.dev/push/) sends notification messages with high priority only where the user expects them, keeps separate channels for approvals and offers, removes tokens FCM reports as dead, and gives you a delivery log per device. For [Approve](https://signops.dev/approve/), the SDK also fetches pending requests when the app opens, so an approval held by a battery saver still reaches the user. Sources: [Firebase: receive messages in an Android app](https://firebase.google.com/docs/cloud-messaging/android/receive), [Android: notification runtime permission](https://developer.android.com/develop/ui/views/notifications/notification-permission), [Android: optimize for Doze and App Standby](https://developer.android.com/training/monitoring-device-state/doze-standby). ### Questions #### Why does FCM return success but the device shows nothing? A success response means FCM accepted the message, not that the phone displayed it. The usual reasons it does not show are a data-only message while the app is killed, a missing notification permission on Android 13 and later, a muted notification channel, a stale token, or a phone maker's battery saver stopping the app. #### Why do notifications work when the app is open but not when it is closed? With a data-only message, Android hands the message to your app's code, and if the app was swiped away on some phones or force-stopped, that code never runs. Send a notification message, which the system tray shows without your code, or a data message with high priority. #### Does a force-stopped app receive FCM messages? No. Android does not deliver broadcasts, including FCM, to an app the user force-stopped in Settings until the user opens it again. Some phone makers treat swiping an app away like a force stop. #### Should every message be high priority? No. High priority wakes the phone from Doze, and Android may lower the priority of apps that send high-priority messages without showing a notification. Use high priority for messages the user is waiting for, like a login approval or a delivery update. ## FCM error codes, and what to do about each URL: https://signops.dev/blog/fcm-error-codes/ Every FCM HTTP v1 error code: UNREGISTERED, INVALID_ARGUMENT, SENDER_ID_MISMATCH, QUOTA_EXCEEDED and more, with the Admin SDK codes and when to retry. Every failed FCM HTTP v1 send returns an HTTP status, a `status` word, and usually an `errorCode` inside `details`. The `errorCode` is the one to act on. This page lists each one, what causes it, whether to retry, and the matching Firebase Admin SDK code, from Google’s own reference. ```json { "error": { "code": 404, "message": "Requested entity was not found.", "status": "NOT_FOUND", "details": [ { "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "UNREGISTERED" } ] } } ``` ### Every FCM v1 error code | errorCode | HTTP | What it means | What to do | Retry | | --- | --- | --- | --- | --- | | `UNREGISTERED` | 404 | The token is dead: app uninstalled, data cleared, app unregistered, or the token refreshed | Delete the token. Never send to it again | No | | `INVALID_ARGUMENT` | 400 | The request is malformed: a bad token, a non-string `data` value, a reserved key, a payload over the limit, a bad TTL | Read `message` and the `fieldViolations` in `details`; fix the field it names | No | | `SENDER_ID_MISMATCH` | 403 | The token belongs to another Firebase project than your service account | Send with the project the app registered under | No | | `QUOTA_EXCEEDED` | 429 | Too many messages: for the project, for one device or for one topic | Slow down; back off from at least 1 minute | Yes, with backoff | | `UNAVAILABLE` | 503 | FCM is overloaded and could not answer in time | Back off exponentially, honour `Retry-After`, add jitter | Yes, with backoff | | `INTERNAL` | 500 | An unknown error inside FCM | Retry as for `UNAVAILABLE`; contact Firebase support if it lasts | Yes, with backoff | | `THIRD_PARTY_AUTH_ERROR` | 401 | The APNs key or certificate, or the web push key, is invalid or missing | Upload a valid APNs `.p8` key in the Firebase project; check Key ID and Team ID | No | | `UNSPECIFIED_ERROR` | varies | No more information | Log the full response and look at `message` | No | ### INVALID_ARGUMENT: the five usual causes The same 400 covers several mistakes. The `message` says which one: - **“The registration token is not a valid FCM registration token”.** The token was cut short, has extra characters (quotes, a newline), or is an APNs device token sent to FCM. Store the token exactly as the app received it from `getToken()`. - **“Invalid value at ‘message.data[0].value’ (TYPE_STRING)”.** Every value in `data` must be a string. `{"order_id": 88213}` fails; `{"order_id": "88213"}` works. - **Message too big.** The payload can be at most 4,096 bytes, or 2,048 bytes for messages to a topic, counting keys and values. - **A reserved key in data.** `from`, `gcm`, and anything starting with `google` are used by FCM itself and are refused. - **A bad TTL.** `android.ttl` is a duration in seconds as a string ending in `s`, like `"3600s"`, from 0 to 2,419,200 (4 weeks). ### Errors from Google before FCM Some failures come from Google’s front door, before FCM reads the message. They have a `status` but no FCM `errorCode`: | status | HTTP | Usual cause | | --- | --- | --- | | `UNAUTHENTICATED` | 401 | The OAuth access token is missing, malformed or expired. Tokens from a service account last one hour; make a new one | | `PERMISSION_DENIED` | 403 | The Firebase Cloud Messaging API is not enabled in the Google Cloud project, or the service account is from another project or lacks permission to send | | `NOT_FOUND` | 404 | With `UNREGISTERED` in details: a dead token. Without it: a wrong project id in the URL | The URL must carry the project id, not the project number or name: `https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send`. ### Firebase Admin SDK error codes The Admin SDKs for Node, Java, Python and Go turn the same answers into their own codes. The ones you will meet: | Admin SDK code | Same as | What to do | | --- | --- | --- | | `messaging/registration-token-not-registered` | `UNREGISTERED` | Delete the token | | `messaging/invalid-registration-token` | `INVALID_ARGUMENT` (token) | Check how the token is stored; do not trim or add characters | | `messaging/invalid-argument` | `INVALID_ARGUMENT` | Read the message | | `messaging/invalid-data-payload-key` | `INVALID_ARGUMENT` (reserved key) | Rename the key | | `messaging/payload-size-limit-exceeded` | `INVALID_ARGUMENT` (size) | Make the payload smaller than 4,096 bytes (2,048 to a topic) | | `messaging/mismatched-credential` | `SENDER_ID_MISMATCH` | Use the service account of the app’s Firebase project | | `messaging/message-rate-exceeded`, `device-message-rate-exceeded`, `topics-message-rate-exceeded` | `QUOTA_EXCEEDED` | Send less to that target; do not retry at once | | `messaging/invalid-apns-credentials` | `THIRD_PARTY_AUTH_ERROR` | Upload a valid APNs key or certificate | | `messaging/authentication-error` | `UNAUTHENTICATED` | Check the credential the SDK starts with | | `messaging/server-unavailable` | `UNAVAILABLE` | Retry with backoff | | `messaging/internal-error` | `INTERNAL` | Retry with backoff | | `messaging/too-many-topics` | | The token is already in the maximum number of topics | With `sendEachForMulticast` the call itself succeeds and each token gets its own result. Check `response.responses[i].error?.code` for every token, not only `failureCount`: ```js const res = await getMessaging().sendEachForMulticast({ tokens, notification: { title, body } }); const dead = []; res.responses.forEach((r, i) => { const code = r.error?.code; if (code === "messaging/registration-token-not-registered" || code === "messaging/invalid-registration-token") dead.push(tokens[i]); }); await db.tokens.deleteMany({ token: { $in: dead } }); // your own store ``` ### A retry rule that holds up 1. **2xx:** done. 2. **404 `UNREGISTERED`, 400 with an invalid token, 403 `SENDER_ID_MISMATCH`:** the token will never work. Remove it. 3. **Any other 400 or 401:** your request or credentials are wrong. Retrying sends the same mistake; log it and fix it. 4. **429, 500, 503:** wait and retry. Start at the `Retry-After` value if there is one, else 1 second (1 minute for `QUOTA_EXCEEDED`), double each time, add a little randomness, and give up after a few tries. Senders that retry hard without backoff can be blocked by Google, so step 4 matters at scale. ### When the send succeeds and nothing shows A `200` with a message `name` only means FCM accepted the message. If the phone stays silent, the problem is on the device: a data-only message to a closed app, a missing notification permission, Doze, or a phone maker’s battery saver. See [FCM says success but the notification never arrives](https://signops.dev/blog/fcm-notification-not-received/) and [push on Xiaomi, Oppo, Vivo and Realme](https://signops.dev/blog/push-notifications-xiaomi-oppo-vivo/). ### What SignOps does with these [SignOps Push](https://signops.dev/push/) sends through FCM HTTP v1 with your own service account and reads each answer the same way: - `UNREGISTERED`, `SENDER_ID_MISMATCH` and an invalid-token `INVALID_ARGUMENT` remove the device and post `device.removed` to your webhook. - 429 and 5xx answers are retried. - Anything else is recorded as the push’s failure reason, which the dashboard shows under **See what Google said**. For the iOS side, see [APNs error codes](https://signops.dev/blog/apns-error-codes/). Sources: [Firebase: FCM error codes](https://firebase.google.com/docs/cloud-messaging/error-codes), [Firebase: ErrorCode reference for HTTP v1](https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode), [Firebase: FCM throttling and quotas](https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas). ### Questions #### What does FCM error UNREGISTERED mean? The token is no longer valid. The app was uninstalled, its data was cleared, it unregistered itself, or Google refreshed the token. It comes back as HTTP 404 with errorCode UNREGISTERED. Delete the token from your database and stop sending to it; the app will register a new one when it next runs. #### Why does FCM return SENDER_ID_MISMATCH? The token was made for a different Firebase project from the one your service account belongs to. A token is tied to the sender that registered it. Use the service account of the project in the app's google-services.json or GoogleService-Info.plist, or register a new token under the project you send from. #### Should I retry FCM errors? Retry only QUOTA_EXCEEDED (429), UNAVAILABLE (503) and INTERNAL (500), with exponential backoff, honouring Retry-After when it is present. For QUOTA_EXCEEDED Google asks for a first delay of at least one minute. Do not retry UNREGISTERED, SENDER_ID_MISMATCH, INVALID_ARGUMENT or THIRD_PARTY_AUTH_ERROR; fix the token, payload or credentials first. #### What is the difference between INVALID_ARGUMENT and UNREGISTERED? INVALID_ARGUMENT (400) means FCM could not accept the request as sent, such as a malformed token, a number inside data, a payload over 4096 bytes or a bad TTL. UNREGISTERED (404) means the request was fine but the token belongs to no app install any more. #### What does THIRD_PARTY_AUTH_ERROR mean in FCM? FCM could not deliver to an iOS device or a web push subscription because the APNs key or certificate in your Firebase project, or the web push key, is invalid or missing. Upload a valid APNs .p8 key under Project settings, Cloud Messaging, and check the Key ID and Team ID. ## APNs error codes, and what to do about each URL: https://signops.dev/blog/apns-error-codes/ Every APNs error reason Apple documents, from BadDeviceToken and DeviceTokenNotForTopic to 410 Unregistered and the provider token errors, with fixes. When a push to `api.push.apple.com` fails, Apple answers with an HTTP status and a JSON body with one `reason`. The reason tells you what to fix. This page lists every reason Apple documents, grouped by status, with the usual cause, the fix, and whether to retry. ```json HTTP/2 410 apns-id: 6A7F2C10-... { "reason": "Unregistered", "timestamp": 1761638400000 } ``` A success is `200` with an empty body. An error body has `reason`, and on a 410 also `timestamp`: the time, in milliseconds, at which Apple confirmed the token was no longer valid. ### The errors you will see most | reason | Status | Usual cause | Fix | | --- | --- | --- | --- | | `BadDeviceToken` | 400 | A sandbox token sent to production, or the other way round; or a token stored with spaces or brackets; or an FCM token sent to APNs | Send development builds to `api.sandbox.push.apple.com` and TestFlight and App Store builds to `api.push.apple.com`. Store the token as plain hex | | `DeviceTokenNotForTopic` | 400 | `apns-topic` is not the bundle id of the app that made the token | Use the app’s exact bundle id, plus `.voip`, `.complication` or `.push-type.liveactivity` for those push types | | `Unregistered` | 410 | The app was deleted, or the token became inactive for the topic | Delete the token | | `ExpiredToken` | 410 | The device token has expired | Delete the token; the app registers a new one when it next runs | | `InvalidProviderToken` | 403 | The JWT’s Key ID (`kid`) or Team ID (`iss`) does not match the `.p8` key, the key was revoked, or the signature is wrong | Check the Key ID and Team ID in the Apple Developer account; sign with ES256 | | `ExpiredProviderToken` | 403 | The JWT is more than an hour old | Make a new token every 20 to 60 minutes | | `TooManyProviderTokenUpdates` | 429 | A new JWT was made for every request, or more often than every 20 minutes | Make one token and reuse it | | `PayloadTooLarge` | 413 | The payload is over 4 KB (5 KB for VoIP) | Send less: move data to your server and send an id | | `TooManyRequests` | 429 | Too many pushes in a row to the same device | Wait, then retry | ### Every APNs error reason From Apple’s table of response error strings: | Status | reason | Meaning | | --- | --- | --- | | 400 | `BadCollapseId` | The `apns-collapse-id` is over the maximum size (64 bytes) | | 400 | `BadDeviceToken` | The device token is invalid or does not match the environment | | 400 | `BadExpirationDate` | The `apns-expiration` value is invalid | | 400 | `BadMessageId` | The `apns-id` value is invalid | | 400 | `BadPriority` | The `apns-priority` value is invalid | | 400 | `BadTopic` | The `apns-topic` value is invalid | | 400 | `DeviceTokenNotForTopic` | The device token does not match the topic | | 400 | `DuplicateHeaders` | A header is repeated | | 400 | `IdleTimeout` | The connection was idle too long | | 400 | `InvalidPushType` | The `apns-push-type` value is invalid | | 400 | `MissingDeviceToken` | No device token in the request path | | 400 | `MissingTopic` | No `apns-topic` header where one is required | | 400 | `PayloadEmpty` | The payload is empty | | 400 | `TopicDisallowed` | Pushing to this topic is not allowed | | 403 | `BadCertificate` | The certificate is invalid | | 403 | `BadCertificateEnvironment` | The certificate is for the other environment | | 403 | `ExpiredProviderToken` | The provider token is stale; make a new one | | 403 | `Forbidden` | The action is not allowed | | 403 | `InvalidProviderToken` | The provider token is not valid or its signature cannot be verified | | 403 | `MissingProviderToken` | No certificate on the connection and no provider token in `authorization` | | 403 | `UnrelatedKeyIdInToken` | The token’s key id is not related to the one used first on this connection; open a new connection | | 403 | `BadEnvironmentKeyIdInToken` | The token’s key id does not match the environment | | 404 | `BadPath` | The `:path` is invalid; it must be `/3/device/` | | 405 | `MethodNotAllowed` | The method is not POST | | 410 | `ExpiredToken` | The device token has expired | | 410 | `Unregistered` | The device token is inactive for the topic | | 413 | `PayloadTooLarge` | The payload is too large | | 429 | `TooManyProviderTokenUpdates` | The provider token is updated too often; at most once every 20 minutes | | 429 | `TooManyRequests` | Too many requests in a row to the same device token | | 500 | `InternalServerError` | An internal error at Apple | | 503 | `ServiceUnavailable` | The service is unavailable | | 503 | `Shutdown` | The APNs server is shutting down | ### Sandbox or production: the cause of most BadDeviceToken A token belongs to one APNs environment: | Build | Environment | Send to | | --- | --- | --- | | Run from Xcode onto a phone | Development (sandbox) | `api.sandbox.push.apple.com` | | TestFlight | Production | `api.push.apple.com` | | App Store | Production | `api.push.apple.com` | The `aps-environment` entitlement in the build decides it. A token key (`.p8`) works for both environments, so the same key is fine; the host is what must match. If you cannot tell which kind of token you have, try production and, on `BadDeviceToken`, try sandbox before deleting the token. ### The provider token (JWT) With a `.p8` key, every request carries `authorization: bearer `, a token you sign yourself: - Header: `{ "alg": "ES256", "kid": "" }` - Claims: `{ "iss": "", "iat": }` - Signed with the `.p8` private key, ES256. Four errors come from getting this wrong. `InvalidProviderToken`: a wrong Key ID or Team ID, or a revoked key. `ExpiredProviderToken`: older than an hour. `TooManyProviderTokenUpdates`: refreshed more than once every 20 minutes. `MissingProviderToken`: no header at all. Make one token, keep it in memory, and replace it after about 50 minutes. ### A retry rule that holds up Apple’s own guidance, as a rule your sender can follow: 1. **200:** done. 2. **`BadDeviceToken`, `DeviceTokenNotForTopic`, `Unregistered`, `ExpiredToken`:** do not retry the token. Delete it if it is really dead; for `BadDeviceToken` check the environment first, and for `DeviceTokenNotForTopic` check your bundle id, since one wrong setting fails every token. 3. **`Forbidden`, `PayloadTooLarge`:** do not retry as is. 4. **Other 4xx:** fix the header, token or payload the reason names, then send again. 5. **`TooManyRequests`:** retry after a delay. 6. **5xx:** wait, then retry with backoff. Apple suggests waiting 15 minutes before retrying a 5xx payload. ### Errors when iOS goes through FCM If your iOS app uses Firebase Messaging, your server talks to FCM and FCM talks to APNs. You then see FCM’s codes instead: a bad or missing APNs key in the Firebase project comes back as `THIRD_PARTY_AUTH_ERROR`, and a dead iPhone token as `UNREGISTERED`. See [FCM error codes](https://signops.dev/blog/fcm-error-codes/). ### What SignOps does with these [SignOps Push](https://signops.dev/push/) can send to iOS straight to APNs with your `.p8` key. With the environment set to **Production and sandbox**, a token that gets `BadDeviceToken` from production is tried on sandbox before it counts as failed. A token Apple answers with 410, `Unregistered`, `BadDeviceToken` or `DeviceTokenNotForTopic` is removed and your webhook gets `device.removed`; 429 and 5xx answers are retried. One provider token is made per key and reused for 50 minutes. The dashboard’s **See what Apple said** shows the reason for each device. The setup, from making the `.p8` key onward, is in the [iOS push guide](https://signops.dev/guides/ios-push/). To reproduce an error by hand, [send a test push with curl](https://signops.dev/blog/apns-curl/). Sources: [Apple: Handling notification responses from APNs](https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns), [Apple: Sending notification requests to APNs](https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns), [Apple: Establishing a token-based connection to APNs](https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns). ### Questions #### What does APNs error 410 Unregistered mean? The device token is no longer active for your app, usually because the app was deleted from the phone. The response carries a timestamp of when Apple confirmed it. Stop sending to the token and delete it, unless the app registers the same token again later than that timestamp. #### Why does APNs return BadDeviceToken? Most often a token from a development build (made with the sandbox environment) is sent to production at api.push.apple.com, or a production token to api.sandbox.push.apple.com. It also happens when the token was stored wrongly, such as with spaces or angle brackets, or when an FCM token is sent to APNs. #### What causes DeviceTokenNotForTopic? The apns-topic header does not match the app the token belongs to. The topic is the app's bundle id, with .voip, .complication or .push-type.liveactivity added for those push types. Check the bundle id in your server's settings against the one in Xcode. #### How often should an APNs provider token be refreshed? Apple rejects a JWT older than one hour with ExpiredProviderToken and rejects refreshing more often than once every 20 minutes with TooManyProviderTokenUpdates. Make one token, reuse it on every request, and make a new one every 20 to 60 minutes, for example every 50. #### Which APNs errors should not be retried? Apple says not to retry BadDeviceToken, DeviceTokenNotForTopic, Forbidden, ExpiredToken, Unregistered or PayloadTooLarge. Other 4xx errors can be retried once the cause is fixed, TooManyRequests after a delay, and 5xx errors after a wait with backoff. ## Why push notifications go missing on Xiaomi, Oppo, Vivo and Realme URL: https://signops.dev/blog/push-notifications-xiaomi-oppo-vivo/ Battery savers on Xiaomi, Redmi, Poco, Oppo, Realme, Vivo and OnePlus stop apps and drop pushes. What developers can change in the app and what users must switch on. The same push arrives instantly on a Pixel and never on a Redmi. FCM is not the problem. Xiaomi, Redmi, Poco, Oppo, Realme, Vivo, iQOO and OnePlus add their own battery management on top of Android. It stops apps the user is not looking at, and a stopped app gets no pushes. These brands sell most of the phones in India, so for an app in India this is the biggest single cause of missing notifications. ### What these phones do - **Autostart off by default.** On MIUI and HyperOS (Xiaomi, Redmi, Poco) and on ColorOS (Oppo, Realme), many apps are not allowed to start themselves. A push that needs your app’s code to run cannot. - **Swipe away acts like force stop.** Clearing an app from recents can put it in the same state as Settings, Force stop. Android does not deliver messages to a force-stopped app until the user opens it. - **Battery saver profiles.** Background activity is restricted per app, sometimes after a few days of the app not being opened. - **Lock screen and floating notifications off.** The notification arrives but only shows in the shade, and the user thinks it never came. ### What you can change in your app 1. **Send notification messages for anything the user must see.** The system tray shows them without your code running, which survives more of these restrictions than data-only messages. See [FCM notification not received](https://signops.dev/blog/fcm-notification-not-received/). 2. **Use high priority for messages the user is waiting for**, like a login approval or an order update, and normal for the rest. 3. **Separate channels.** Logins and offers on different channels, so muting one does not mute the other. 4. **Ask once, at the right moment.** When a user turns on something that depends on pushes, show one screen explaining the setting and a button that opens it. Do not show it to everyone on day one. 5. **Pull as well as push.** When the app opens, fetch anything the user should have been told. Pushes that never arrived are then only late, not lost. #### Opening the right settings screen Android gives you a standard screen for battery optimization. The maker-specific Autostart screens have no public API, so link to the app’s settings page and tell the user which switch to turn on. ```kotlin // The system list where the user can exempt your app from battery optimization startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) // Your app's own settings page, where Autostart and battery options live on most phones startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null))) ``` Google Play allows apps to ask for a direct exemption (`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`) only in a few cases, so most apps should use the settings screen. ### What to tell users, brand by brand | Phone | Settings to change | | --- | --- | | Xiaomi, Redmi, Poco (MIUI, HyperOS) | Turn on **Autostart** for the app. Battery saver: **No restrictions**. Notifications: allow on lock screen and floating. Lock the app in recents. | | Oppo, Realme (ColorOS, Realme UI) | Battery: **Allow background activity** and **Allow auto launch**. Notifications: allow all. | | Vivo, iQOO (Funtouch OS, OriginOS) | Battery: allow **High background power consumption**. Turn on **Autostart** in i Manager or Settings. | | OnePlus (OxygenOS) | Battery optimization: **Don’t optimize** for the app. Turn off **Deep optimization** if the phone has it. | Menu names move between versions. [dontkillmyapp.com](https://dontkillmyapp.com/) keeps current instructions for each brand. ### How to tell if this is your problem Send the same notification to a Pixel or Samsung and to the Xiaomi, both with the app swiped away. If it shows on the first and not the second, it is the battery saver, and no server change will fix it alone. ### How SignOps handles it [SignOps Push](https://signops.dev/push/) sends notification messages with high priority only where the user expects them, and the delivery log shows which devices accepted each push. For [Approve](https://signops.dev/approve/), the SDK fetches pending approvals every time your app opens, so an approval held back by a battery saver still reaches the user, and your server can fall back to an [email code](https://signops.dev/email-otp/) if nobody answers. Sources: [Don’t kill my app](https://dontkillmyapp.com/), [Android: optimize for Doze and App Standby](https://developer.android.com/training/monitoring-device-state/doze-standby), [Firebase: receive messages in an Android app](https://firebase.google.com/docs/cloud-messaging/android/receive). ### Questions #### Why are notifications not showing on my Xiaomi phone? Xiaomi's MIUI and HyperOS stop apps in the background to save battery. If Autostart is off or battery saver restricts the app, its pushes are delayed or dropped. Turn on Autostart, set battery saver to No restrictions and allow notifications on the lock screen. #### Can an app turn off battery optimization by itself? Not silently. An app can open the system screen where the user exempts it, using Android's battery optimization settings intent. Google Play allows asking for a direct exemption only for a few kinds of apps, so most apps should guide the user to the setting instead. #### Is this a Firebase problem? No. FCM delivers to the phone; the phone maker's software decides whether your app may wake up and show it. The same message shows at once on a Pixel and late or never on a restricted Xiaomi. #### What is dontkillmyapp.com? A community site that lists, phone maker by phone maker, how their battery management kills background apps and which settings undo it. It is the most useful single reference for this problem. ## Is Firebase push notification free or paid? URL: https://signops.dev/blog/firebase-push-notification-pricing/ Yes. FCM is free on every Firebase plan, with no message limit. The quotas: 600,000 messages a minute, 240 a minute to one phone. What costs money around it. **Yes, Firebase push notifications are free.** Firebase lists Cloud Messaging (FCM) as “no-cost” on both its free Spark plan and its paid Blaze plan. Google charges nothing per message, and there is no monthly limit on how many you send. Apple’s push service (APNs) is free to send through too. So why do OneSignal, Pusher and others charge? Because sending is free, and everything around sending is not. ### What is free | | Cost | | --- | --- | | FCM messages to Android, iOS and web | Free, unlimited | | APNs messages to iPhones | Free (needs the Apple Developer Program, which any iOS app has) | | Web push through browsers’ push services | Free | | Topics and topic subscriptions on FCM | Free | ### FCM quotas and rate limits (HTTP v1) Sending is free, but not unlimited in speed. These are Firebase’s documented limits: | Limit | Value | | --- | --- | | Messages per project | 600,000 a minute, counted per message, not per request | | Over a quota | `429` with error code `QUOTA_EXCEEDED`, for the project, one device or a topic | | Messages to one Android device | 240 a minute and 5,000 an hour | | Collapsible messages to one device | A burst of 20, then 1 every 3 minutes; extras are delayed, not dropped | | Payload | 4 KB per message | Client errors (`400` to `499`, except `429`) count against the quota, so a list full of dead tokens uses it up faster. The minute is not aligned to the clock, so a retry at :00 can still fail. Firebase asks for exponential backoff starting at no less than 1 minute for messages rejected with `429`. **Raising it:** in Google Cloud Console, open Quotas and system limits for the Firebase Cloud Messaging API and edit _Send requests per minute_. Google considers up to 25% more once usage sits at 80% of the quota for at least 5 minutes a day with client errors under 5%. For a launch or a sale, ask Firebase Support for a temporary increase at least 15 days ahead (30 days above 18 million a minute), at most twice a year. Other limits worth knowing: - **Topics:** fan-out to large topics is not instant; plan for minutes, not seconds, on very large sends. - **No delivery guarantee:** FCM accepting a message does not mean the phone showed it. See [why FCM notifications are not received](https://signops.dev/blog/fcm-notification-not-received/). ### What is not free: the server around it FCM delivers to a token. Everything else is yours to build and run: 1. **A database of tokens per user**, updated when a token changes, a user reinstalls or signs in on another phone. 2. **Sending logic:** one user to many devices, lists of users, topics, everyone. 3. **Scheduling** and time zones. 4. **Cleanup:** deleting tokens FCM reports as `UNREGISTERED`, or every send gets slower and every count wrong. 5. **A screen for non-developers** who write offers and reminders. 6. **Delivery logs** to answer “did my user get it”. 7. **Credentials:** HTTP v1 needs a service account and short-lived tokens. See [the FCM HTTP v1 migration](https://signops.dev/blog/fcm-http-v1-migration/). That work is what paid push services sell. ### What that costs to buy | Service | Free | Paid | | --- | --- | --- | | FCM directly | Unlimited, you build the server | Your own servers and time | | Expo push | Unlimited, Expo apps only | Free | | OneSignal | Mobile push up to 1,000 users | $19 a month plus $0.012 a user | | Pusher Beams | 1,000 devices | $29 a month for 10,000 | | [SignOps](https://signops.dev/pricing/) | 1,000 devices | Rs 499 a month for 5,000, then Rs 0.20 a device | ### The short answer If you have a backend and one app, FCM directly is free and fine. When you need users across devices, topics, scheduling, a composer for your team or delivery logs, you either build that server or pay for one. [SignOps Push](https://signops.dev/push/) is that server, with your own Firebase and Apple keys, free for 1,000 devices. Sources: [Firebase pricing](https://firebase.google.com/pricing), [Firebase: FCM throttling and quotas](https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas), [FCM error codes](https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode). ### Questions #### Is Firebase Cloud Messaging free? Yes. Firebase lists Cloud Messaging as no-cost on both the free Spark plan and the pay-as-you-go Blaze plan. There is no charge per message and no monthly cap. #### What is the FCM free limit? There is no message limit. There is a rate quota of 600,000 messages a minute per Firebase project by default, which Google can raise for projects that need more. #### What is the FCM HTTP v1 quota? 600,000 messages a minute per project, counted in messages, not requests. Past it the API answers 429 QUOTA_EXCEEDED; retry with backoff starting at 1 minute. One Android device takes up to 240 messages a minute and 5,000 an hour. #### How do I raise the FCM quota? In Google Cloud Console, under Quotas and system limits for the Firebase Cloud Messaging API, edit Send requests per minute. Google considers up to 25% more once you sit at 80% of the quota for 5 minutes a day with under 5% client errors. For a launch, ask Firebase Support at least 15 days ahead. #### Do I pay for iOS pushes? Apple charges nothing to send through APNs. You need the Apple Developer Program membership that every iOS app needs anyway. #### Then why do push services charge? For the server around FCM. Storing tokens per user, targeting topics, scheduling, retrying, removing dead tokens, a dashboard for non-developers and delivery logs are all work that FCM leaves to you. ## Sending push notifications from a Node.js server URL: https://signops.dev/blog/send-push-notification-node-js/ Send FCM push notifications from Node.js: firebase-admin setup, one user, many tokens, topics, all users, scheduling and cleaning up dead tokens. This is the server side of push in Node.js with the Firebase Admin SDK: one device, one user with many devices, many users, everyone, and keeping your token list clean. It uses the HTTP v1 API, which replaced the legacy API in 2024. ### Setup ```bash npm install firebase-admin ``` Download a service account key from the Firebase console (**Project settings, Service accounts**) and keep it out of your repo, for example in an environment variable. ```js import { initializeApp, cert } from "firebase-admin/app"; import { getMessaging } from "firebase-admin/messaging"; initializeApp({ credential: cert(JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT)) }); const fcm = getMessaging(); ``` ### Send to one device ```js await fcm.send({ token: deviceToken, notification: { title: "Your order is out for delivery", body: "Ravi is 10 minutes away" }, data: { order_id: "88213" }, // strings only android: { priority: "high" }, }); ``` ### Send to every device of one user FCM knows tokens, not users. Store tokens against your user id, then send to all of them. ```js const tokens = await db.tokens.find({ user_id: "usr_8f21c4" }); // your table const res = await fcm.sendEachForMulticast({ tokens, // up to 500 notification: { title: "New login on Chrome", body: "Was this you?" }, }); // Remove tokens FCM says are dead res.responses.forEach((r, i) => { if (r.error?.code === "messaging/registration-token-not-registered") db.tokens.delete(tokens[i]); }); ``` ### Send to everyone, or a segment Use topics. Subscribe when a device registers: ```js await fcm.subscribeToTopic(tokens, "all"); await fcm.subscribeToTopic(tokens, "city-pune"); ``` Then one call reaches them all: ```js await fcm.send({ topic: "offers", notification: { title: "Weekend offer", body: "20% off fruit" } }); ``` ### Scheduling FCM sends immediately. To send at 6 pm, schedule the call yourself: a job queue such as BullMQ, a cron job, or a cloud scheduler that calls your endpoint. Store the job id so it can be cancelled. ### Things that go wrong in production - **Numbers in `data`.** Every value must be a string, or FCM returns `INVALID_ARGUMENT`. - **Tokens never cleaned.** Delete on `registration-token-not-registered`; otherwise lists grow and sends slow down. - **Sending one by one in a loop.** Use `sendEachForMulticast` or topics, and respect the default quota of 600,000 messages a minute per project. - **Silent data messages for things users must see.** They are not shown when the app is killed on many phones. See [why FCM notifications are not received](https://signops.dev/blog/fcm-notification-not-received/). A runnable version of the direct FCM send, with the error handling above, is in [node-fcm-v1 on GitHub](https://github.com/signops-dev/signops-examples/tree/main/node-fcm-v1). ### The same with SignOps [SignOps Push](https://signops.dev/push/) keeps the token table, topics, scheduling and cleanup for you. With your Firebase and Apple keys uploaded once: ```js import { SignOps } from "@signops/node"; const signops = new SignOps(process.env.SIGNOPS_KEY); await signops.push.send({ user_id: "usr_8f21c4", // every device of this user title: "Your order is out for delivery", body: "Ravi is 10 minutes away", send_at: "2026-10-24T18:00:00+05:30", // optional }); ``` Sources: [Firebase: send messages with the Admin SDK](https://firebase.google.com/docs/cloud-messaging/send-message), [Firebase: topic messaging](https://firebase.google.com/docs/cloud-messaging/android/topic-messaging). ### Questions #### How do I send a push notification to all users with Firebase? Subscribe every device to a topic such as all when it registers, then send one message to that topic. HTTP v1 has no single call that reaches every token of a project. #### Can I schedule a push notification with FCM? Not from the API. FCM sends immediately. Schedule from your own server with a job queue or cron, or use the Firebase console's campaign scheduler for one-off messages. #### How many tokens can sendEachForMulticast take? Up to 500 tokens per call. For more, split the list, or use a topic. ## Expo push notifications work in development but not in the APK URL: https://signops.dev/blog/expo-push-notifications-not-working/ The common reasons Expo push notifications stop in production builds: FCM v1 credentials, google-services.json, Expo Go limits, receipts and Android channels. The push works in development and stops in the APK, or works on iOS and not Android. Almost every case is one of five things. Check them in this order. ### 1. No FCM v1 key in EAS On Android, Expo sends through **your** Firebase project. Since Google retired the FCM legacy API in 2024, Expo needs an **FCM v1 service account key** for your project. Without it, Android pushes fail while iOS keeps working. Upload it once: ```bash eas credentials ## Android > production > Google Service Account ## > Manage your Google Service Account Key for Push Notifications (FCM V1) ``` You can also do it on expo.dev under your project’s Credentials, Android, then your application id, then **FCM V1 service account key**. ### 2. google-services.json missing from the build The build must include the `google-services.json` from the **same** Firebase project the key came from. Point to it in `app.json`: ```json { "expo": { "android": { "package": "in.kiranaexpress.app", "googleServicesFile": "./google-services.json" } } } ``` A file from another project gives you tokens that your key cannot send to. ### 3. Testing in Expo Go Recent Expo SDKs removed remote push from Expo Go on Android, and Expo Go never used your own credentials anyway. Make a development build with `eas build --profile development` and test there. ### 4. Reading the ticket instead of the receipt The push API answers with a **ticket**. `"status": "ok"` means Expo queued it. Delivery problems appear later in the **receipt**: ```js // after sending, keep the ticket ids, then a few minutes later: const res = await fetch("https://exp.host/--/api/v2/push/getReceipts", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids: ticketIds }), }); const { data } = await res.json(); // data[id].status === "error" with details.error such as // "DeviceNotRegistered" (delete the token) or "InvalidCredentials" (fix step 1) ``` ### 5. Android’s own reasons Once Expo hands the message to FCM, the usual Android causes apply: the notification permission on Android 13 and later, a missing channel, and battery savers on Xiaomi, Oppo and Vivo. Create a channel at start with `Notifications.setNotificationChannelAsync`, ask for permission with `requestPermissionsAsync`, and read [why pushes go missing on Xiaomi, Oppo and Vivo](https://signops.dev/blog/push-notifications-xiaomi-oppo-vivo/). ### Sending from your own server instead Expo’s push service is free and fine for Expo apps. If you also want topics, scheduling, a composer for your marketing team, push approval for logins or email codes on the same user id, [SignOps Push](https://signops.dev/push/) takes your FCM and APNs credentials directly and works the same for Expo, bare React Native, Flutter and native apps. Every ticket, receipt and request error Expo can return is in [Expo push errors](https://signops.dev/blog/expo-push-errors/). Sources: [Expo: FCM v1 migration](https://expo.dev/blog/fcm-v1-migration-deadline-changed-to-july-20th), [Expo: support for FCM HTTP v1](https://expo.dev/blog/expo-adds-support-for-fcm-http-v1-api), [Expo forums: push not working after EAS build](https://forums.expo.dev/t/push-notifications-is-not-working-after-eas-build/53468). ### Questions #### Why do Expo push notifications work in Expo Go but not in my APK? A standalone Android build sends through your own Firebase project, so it needs your google-services.json in the build and an FCM v1 service account key uploaded to EAS. Expo Go uses Expo's own credentials, which hides the problem until you build. #### Do Expo push notifications work in Expo Go on Android? Recent Expo SDKs removed remote push notifications from Expo Go on Android. Test pushes in a development build made with EAS instead. #### The Expo push API says ok but nothing arrives. Why? The ticket only means Expo accepted the message. Fetch the push receipt a few minutes later; errors such as DeviceNotRegistered or InvalidCredentials appear there, not in the ticket. ## What an SMS OTP costs in India, and what DLT asks of you URL: https://signops.dev/blog/sms-otp-cost-india/ SMS OTP charges in India per message, what 1,000 and 1 lakh OTPs cost, what DLT registration involves, and the options that need no DLT. An SMS OTP in India costs about **Rs 0.13 to Rs 0.25 per message** from Indian providers in 2026, plus 18% GST. Global providers cost far more: Twilio lists about Rs 7.90. Before you can send the first one, you also need DLT registration. Here is what each provider charges, what DLT involves, and when you can skip SMS entirely. ### Price per SMS OTP | Provider | Price per OTP SMS | | --- | --- | | MSG91 | Rs 0.25 for small packs, down to Rs 0.16 from about 9.6 lakh; Rs 0.13 by quote | | Gupshup | About Rs 0.17 | | Kaleyra | About Rs 0.18 | | Twilio (India) | $0.0832 list price, about Rs 7.90; domestic routes by quote | Prices are before 18% GST and move with volume. MSG91 and Twilio were checked on their own price pages on 21 October 2026; dollars at Rs 95. The real cost per _successful login_ is higher than the price per message: users who do not get the code tap Resend, and each resend is billed. #### What it adds up to | Logins a month | At Rs 0.17 | With 20% resends | | --- | --- | --- | | 1,000 | Rs 170 | Rs 204 | | 10,000 | Rs 1,700 | Rs 2,040 | | 1 lakh | Rs 17,000 | Rs 20,400 | | 10 lakh | Rs 1,70,000 | Rs 2,04,000 | For your own numbers, with WhatsApp and email next to SMS, use the [OTP cost calculator](https://signops.dev/tools/otp-cost-calculator/). ### What DLT asks of you TRAI’s Telecom Commercial Communications Customer Preference Regulations (TCCCPR) 2018 put all commercial SMS in India on Distributed Ledger Technology (DLT) platforms run by the operators. For OTP you register: 1. **Your business** as a Principal Entity, with company documents. 2. **A header**, the sender id users see, such as `KIRANA`. 3. **Each message template**, with variables for the code, for example `Your Kirana Express code is {#var#}. It expires in 10 minutes.` Your SMS provider attaches the registered ids to every message. A message that does not match a registered template is blocked by the network. Plan for registration and template approval to take days, not minutes. ### Can you send OTP without DLT? **Not by SMS through legitimate routes.** Messages to Indian numbers from unregistered senders or templates are filtered by the operators, and offers of “OTP without DLT” usually mean international routes that deliver poorly and break the rules. You can, however, verify users **without SMS**, and none of these need DLT: | Method | Cost each | Needs DLT | | --- | --- | --- | | [Email OTP](https://signops.dev/email-otp/) | Rs 0.025 on SignOps | No | | WhatsApp authentication message | About Rs 0.115 plus GST (Meta’s rate) | No, but needs WhatsApp Business approval | | [Push approval on a trusted phone](https://signops.dev/approve/) | Rs 0.02 on SignOps past the daily allowance, about Rs 4.75 on Twilio | No | | [Passkey](https://signops.dev/passkeys/) | Rs 0 on SignOps | No | ### A sensible mix Most apps need SMS once per user: to prove a phone number at signup. After that, logins can move to cheaper or safer methods. Email codes cost about a sixth of an SMS. Push approvals cost more than an SMS but cannot be read out to a caller or caught by a SIM swap. Passkeys cost nothing per sign-in. See [OTP service providers in India](https://signops.dev/otp-service-provider-india/) for a side-by-side, and [email OTP vs SMS OTP](https://signops.dev/blog/email-otp-vs-sms-otp/) for when each fits. Sources: [MSG91 SMS pricing](https://msg91.com/in/pricing/sms), [Twilio SMS pricing for India](https://www.twilio.com/en-us/sms/pricing/in), [Message Central: SMS OTP pricing in India](https://www.messagecentral.com/blog/sms-otp-pricing-india), [Message Central: India SMS guidelines and DLT](https://www.messagecentral.com/sms-guideline/india), [WhatsApp Business API pricing in India](https://www.authgear.com/post/whatsapp-api-pricing). ### Questions #### How much does an SMS OTP cost in India? About Rs 0.13 to Rs 0.25 per message from Indian providers in 2026, depending on volume, plus 18% GST. MSG91 lists Rs 0.25 for small packs down to Rs 0.16 at high volume, and Rs 0.13 by quote. Twilio's list price to India is $0.0832, about Rs 7.90. #### What do 1,000 OTP SMS cost in India? About Rs 130 to Rs 250 plus 18% GST from Indian providers such as MSG91, Gupshup and Kaleyra, depending on the provider and how many you buy. Resends add to it. With 20% of users tapping Resend, 1,000 sign-ins at Rs 0.17 cost about Rs 204. #### Is DLT registration mandatory for OTP SMS? Yes. Under TRAI's TCCCPR 2018 rules, every commercial SMS sent to Indian numbers, OTPs included, must come from a registered entity, sender id (header) and template on a DLT platform. Unregistered templates are blocked by the operators. #### Can I send OTP without DLT registration? Not by SMS through legitimate Indian routes. You can verify users without SMS, and so without DLT, by email OTP, WhatsApp authentication messages, push approval on a trusted phone, or passkeys. #### Is WhatsApp OTP cheaper than SMS? In India, Meta lists authentication messages at about Rs 0.115 each plus GST, which is a little cheaper than most SMS OTP routes, before any provider's fee. ## Supabase "email rate limit exceeded": the fix URL: https://signops.dev/blog/supabase-email-rate-limit/ Supabase's built-in email sender is for testing and allows only a few emails an hour. How custom SMTP raises the limit, and other ways to send sign-in codes. You call `signInWithOtp` a few times while testing and get **`email rate limit exceeded`**. Nothing is broken. Supabase’s built-in email sender is a test service with a very small hourly limit, and production apps are expected to bring their own SMTP. ### Why it happens Supabase sends auth emails (sign-up confirmations, magic links, email OTP codes, password resets) through a built-in sender unless you configure your own. That sender: - allows **only a few emails an hour per project** (2 an hour at the time of writing), - is meant for development, not real users, - may only deliver to addresses on your project team. Every auth email type counts against the same limit, so two test sign-ins can use it up. ### The fix: custom SMTP 1. Pick an email provider: Amazon SES, ZeptoMail, Resend, Brevo, Postmark or SendGrid all work. 2. Verify your sending domain with them (they give you DKIM and SPF records to add to DNS). 3. In Supabase open **Authentication, Emails, SMTP Settings**, turn on custom SMTP, and enter the host, port, user and password from the provider. 4. Open **Authentication, Rate Limits** and set the email limit you need. Your emails now go through your provider, with your domain as the sender, and you pay that provider per email. ### Sending a 6-digit code instead of a link Supabase’s `signInWithOtp` sends a magic link by default. To send a code, edit the **Magic Link** email template to include `{{ .Token }}`, then verify the code the user types: ```js await supabase.auth.signInWithOtp({ email: "ananya@example.in" }); // later, with the code the user typed const { data, error } = await supabase.auth.verifyOtp({ email: "ananya@example.in", token: "482913", type: "email", }); ``` ### Other ways to send sign-in codes If you would rather not run SMTP, deliverability and templates yourself: - **Use an email OTP API** that makes, sends and checks the code. [SignOps Email OTP](https://signops.dev/email-otp/) is two calls, send and verify, with 500 codes free to try and 2,000 a month on Starter, sent from your own domain. You verify the code on your server, then sign the user in to Supabase from there. - **Skip email for returning users.** A [passkey](https://signops.dev/passkeys/) or a [push approval](https://signops.dev/approve/) on a phone they already use sends no email at all. Sources: [Supabase: send emails with custom SMTP](https://supabase.com/docs/guides/auth/auth-smtp), [Supabase: passwordless email logins](https://supabase.com/docs/guides/auth/auth-email-passwordless), [SendLayer: Supabase custom SMTP guide](https://sendlayer.com/blog/supabase-custom-smtp-and-email-configuration-guide/). ### Questions #### What is Supabase's email rate limit on the free tier? Supabase's built-in email sender is meant for testing and allows only a few auth emails an hour per project (2 an hour at the time of writing). Check Authentication, Rate Limits in your project for the current figure. #### How do I increase the Supabase email rate limit? Set up custom SMTP under Authentication, Emails, SMTP Settings with any email provider. Once custom SMTP is on, you can raise the email limit on the Rate Limits page. #### Does Supabase charge for email OTP? Supabase does not charge per email OTP; it sends through its built-in sender for testing or through the SMTP provider you configure, who bills you for the emails. ## Email OTP in Better Auth, end to end URL: https://signops.dev/blog/better-auth-email-otp/ Set up the Better Auth email OTP plugin, choose a sender for the codes, and handle expiry, tries and rate limits in production. Better Auth’s **email OTP plugin** handles the hard parts of a 6-digit email code: generating it, storing it, expiring it and checking it. It leaves one thing to you, which is sending the email. This post sets the plugin up and covers the choices that matter in production. ### Server setup ```ts // auth.ts import { betterAuth } from "better-auth"; import { emailOTP } from "better-auth/plugins"; export const auth = betterAuth({ // your database adapter, as usual plugins: [ emailOTP({ otpLength: 6, expiresIn: 600, // seconds maxAttempts: 5, async sendVerificationOTP({ email, otp, type }) { // type: "sign-in" | "email-verification" | "forget-password" | "change-email" await sendEmail({ to: email, subject: `Your Kirana Express code is ${otp}`, text: `Enter ${otp} to continue. It expires in 10 minutes.`, }); }, }), ], }); ``` ### Client setup ```ts import { createAuthClient } from "better-auth/client"; import { emailOTPClient } from "better-auth/client/plugins"; export const authClient = createAuthClient({ plugins: [emailOTPClient()] }); // 1. send the code await authClient.emailOtp.sendVerificationOtp({ email, type: "sign-in" }); // 2. sign in with the code the user typed await authClient.signIn.emailOtp({ email, otp }); ``` ### Choosing what sends the email `sendEmail` above is yours. Three things decide whether codes arrive: 1. **A verified sending domain.** Send from `noreply@yourapp.in` with SPF, DKIM and DMARC set up. Codes from a Gmail address or an unverified domain land in spam. 2. **A transactional provider, not a mailbox.** Amazon SES, ZeptoMail, Resend or Postmark. Zoho Mail or Gmail SMTP have hourly limits and policies against bulk sending. 3. **Plain, short emails.** The code in the subject line, no images, no marketing. Inbox providers treat code emails with links and images with more suspicion. ### Production checklist - **Rate limit the send endpoint** per email and per IP, and put a check such as Cloudflare Turnstile on the form. Otherwise anyone can use your app to email strangers. - **A resend wait** of 30 seconds or so in your UI. - **Expiry and tries:** 10 minutes and 5 tries are common; Better Auth’s defaults at the time of writing are 300 seconds and 3 tries. - **Watch bounces and complaints.** A spike of either gets your sending domain throttled. ### Or let an API send and check the code If you would rather not store codes or manage email at all, [SignOps Email OTP](https://signops.dev/email-otp/) makes the code, sends it from your domain or ours, and checks it in two calls. Use it in a custom Better Auth plugin or your own route, and create the session once SignOps says the code is valid. Sources: [Better Auth: email OTP plugin](https://better-auth.com/docs/plugins/email-otp). ### Questions #### Does Better Auth send the OTP email itself? No. The email OTP plugin creates and checks the code, and calls your sendVerificationOTP function to deliver it. You choose the email provider. #### What are the Better Auth email OTP defaults? At the time of writing the docs list a 6-digit code, a 300-second expiry and 3 tries before the code is invalid. All three can be changed with otpLength, expiresIn and maxAttempts. Check the docs for your installed version. #### Can I use Better Auth email OTP for sign-up and password reset? Yes. The type passed to sendVerificationOTP tells you which flow it is (sign-in, email-verification, forget-password or change-email), so you can word each email differently. ## Firebase Auth has no email OTP. Here is how to add one URL: https://signops.dev/blog/firebase-auth-email-otp/ Firebase Auth offers email links, not 6-digit email codes. How to build email OTP sign-in with a Cloud Function and a custom token. Firebase Auth has phone sign-in by SMS and **email link** sign-in, but no **email OTP**: no 6-digit code the user types back. Links break on phones because the email app often opens them in a different browser from the one where sign-in started. Here is how to add a real code, keeping Firebase as your login system. ### The idea 1. Your server makes a 6-digit code, emails it, and keeps a hash of it with an expiry. 2. The user types the code into your app. 3. Your server checks it, then creates a **Firebase custom token** for that user with the Admin SDK. 4. The app calls `signInWithCustomToken`, and the user is signed in to Firebase like any other. ### Server: send and check ```js import { getAuth } from "firebase-admin/auth"; import crypto from "node:crypto"; // Send export async function sendCode(email) { const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, "0"); const hash = crypto.createHmac("sha256", process.env.OTP_SECRET).update(email + code).digest("hex"); await db.codes.put(email, { hash, expires: Date.now() + 10 * 60_000, tries: 0 }); await sendEmail(email, `Your code is ${code}`); } // Check, then hand back a Firebase custom token export async function verifyCode(email, code) { const row = await db.codes.get(email); if (!row || row.expires < Date.now() || row.tries >= 5) throw new Error("expired"); const hash = crypto.createHmac("sha256", process.env.OTP_SECRET).update(email + code).digest("hex"); if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(row.hash))) { await db.codes.update(email, { tries: row.tries + 1 }); throw new Error("wrong code"); } await db.codes.delete(email); const user = await getAuth().getUserByEmail(email).catch(() => getAuth().createUser({ email, emailVerified: true })); return getAuth().createCustomToken(user.uid); } ``` ### App: sign in with the token ```js import { getAuth, signInWithCustomToken } from "firebase/auth"; const { token } = await fetch("/api/verify-code", { method: "POST", body: JSON.stringify({ email, code }) }).then((r) => r.json()); await signInWithCustomToken(getAuth(), token); ``` ### What you still have to get right - **Store only a hash** of the code, with an expiry and a try count, as above. - **Rate limit** sends per email and per IP, and add a bot check on the form. - **Email delivery:** a verified domain and a transactional provider, or codes land in spam. - **Cleanup:** delete codes once used or expired. ### The same with SignOps [SignOps Email OTP](https://signops.dev/email-otp/) does the first two steps (making, storing, sending and checking the code), so your server only creates the custom token: ```js const { otp_id } = await signops.otp.send({ email, app_name: "Kirana Express" }); // later const { valid } = await signops.otp.verify({ otp_id, code }); if (valid) return getAuth().createCustomToken(uid); ``` 500 codes are free to try, and Starter includes 1,000 a month. Sources: [Firebase: create custom tokens](https://firebase.google.com/docs/auth/admin/create-custom-tokens), [Firebase: email link authentication](https://firebase.google.com/docs/auth/web/email-link-auth). ### Questions #### Does Firebase Auth support email OTP? Not as a 6-digit code. Firebase Auth offers email link sign-in, where the user taps a link in the email. For a typed code, generate and check it yourself and sign the user in with a custom token. #### Is a custom token safe to use for this? Yes, if only your server creates it and only after it has checked the code. Custom tokens are created with the Admin SDK and your service account, which never leaves the server. #### Why not just use email link sign-in? Links open in whatever browser the email app chooses, which often is not the app the user started in. A code the user types back into the same screen avoids that, which matters most on phones. ## Email OTP or SMS OTP? URL: https://signops.dev/blog/email-otp-vs-sms-otp/ Email OTP and SMS OTP compared on price in India, DLT, delivery, SIM swap and phishing risk, with when to use each. Both send a short code the user types back. They differ on cost, setup, delivery and what an attacker needs to steal one. ### Side by side | | SMS OTP | Email OTP | | --- | --- | --- | | Cost each in India | About Rs 0.13 to Rs 0.25 | A few paise to send; Rs 0.025 on [SignOps](https://signops.dev/email-otp/) | | Setup | DLT entity, header and template registration | A verified sending domain (SPF, DKIM, DMARC) | | Proves | The user has the phone number | The user has the inbox | | Delivery | Usually seconds; can fail on weak signal or DND issues | Usually seconds; can land in spam if the domain is not set up | | SIM swap risk | Yes | No | | Phishing risk | Yes, users read codes out to callers | Yes | | Works without a phone | No | Yes, on any device with email | ### When SMS is the right choice - You need to **prove a phone number**, for example for a delivery or payments app, or for KYC. - Your users may not use email much. Even then, you usually need it **once per user**, at signup. ### When email is the right choice - Logins for web apps and SaaS, where users already sign up with email. - You want to avoid DLT registration and template approval. - Cost matters at volume: 1 lakh logins a month cost about Rs 17,000 by SMS at Rs 0.17 and about Rs 2,500 by email at Rs 0.025. The [OTP cost calculator](https://signops.dev/tools/otp-cost-calculator/) works it out for your own numbers. ### Better than both for returning users Codes of either kind can be phished. For users who already signed in on a phone: - **[Push approval](https://signops.dev/approve/):** the phone shows who is signing in and asks for a number match and Face ID. Nothing to read out. - **[Passkeys](https://signops.dev/passkeys/):** a fingerprint or screen lock, and nothing to phish at all. A sensible flow: SMS or email once at signup, then passkeys or push approval for logins, with email codes as the fallback. See [passkeys vs OTP](https://signops.dev/blog/passkeys-vs-otp/) and [SMS OTP cost in India](https://signops.dev/blog/sms-otp-cost-india/). ### Questions #### Is email OTP more secure than SMS OTP? It avoids SIM swap and SMS interception, but anyone with access to the inbox can read it, and both can be phished. Neither is as strong as a passkey or a signed push approval. #### Which is cheaper in India, email or SMS OTP? Email. An SMS OTP costs about Rs 0.13 to Rs 0.25 from Indian providers and needs DLT registration; an email code costs a few paise to send, and Rs 0.025 on SignOps. #### Do I need DLT for email OTP? No. DLT applies to commercial SMS in India. Email codes need a verified sending domain instead. ## Passkeys or OTP? URL: https://signops.dev/blog/passkeys-vs-otp/ How passkeys work, why they cannot be phished like an OTP, which devices support them, and how to add them next to the login you already have. An OTP proves you can read a code sent to you. A passkey proves you hold a key made for this one website, unlocked by your fingerprint or screen lock. That difference is why passkeys are replacing codes for sign-in. ### How a passkey works 1. **Registration.** The phone or laptop makes a key pair for your domain. The private key stays on the device or in the user’s password manager. Your server stores the public key. 2. **Sign-in.** Your server sends a random challenge. The device asks for the fingerprint or screen lock, signs the challenge with the private key, and your server checks the signature with the public key. The browser only lets a passkey answer the domain it was made for. A look-alike phishing site gets nothing. ### Passkeys vs OTP | | SMS or email OTP | Passkey | | --- | --- | --- | | What the user does | Waits for a code, types it | One touch or glance | | Can be phished | Yes | No | | SIM swap risk | Yes for SMS | No | | Cost per sign-in | Rs 0.025 to Rs 0.25 | Nothing to send | | Works offline | No | Yes, on the device | | Needs a new device to be set up | No | Yes, once per device or synced account | ### Where OTP still fits - **Proving a new email or phone number** at signup. - **Recovery** when someone loses every device. - **Users on old devices** without passkey support. So the usual design is passkeys for sign-in, email OTP for signup and recovery. ### Adding passkeys The browser API is WebAuthn (`navigator.credentials.create` and `.get`). The work is on your server: making challenges, storing credentials and checking every response correctly. A minimal flow: ```js // Browser, with SignOps: your server makes a registration token for the signed-in user, // and the browser makes the passkey with it import { passkeys } from "@signops/web"; await passkeys.register({ token: registrationToken }); // Later: sign in, with the passkey offered in the email field (autofill) const { signops_token } = await passkeys.signIn({ autofill: true }); // your server confirms signops_token before starting a session ``` On Android, passkeys go through Credential Manager; on iOS through AuthenticationServices. Flutter and React Native packages wrap both. To see what a browser and device support, open the [passkey support checker](https://signops.dev/tools/passkey-check/) on it. [SignOps Passkeys](https://signops.dev/passkeys/) runs the server side on your own domain, with 1,000 sign-ins a month free and 50,000 on Starter, and [email OTP](https://signops.dev/email-otp/) on the same account for recovery. Sources: [passkeys.dev](https://passkeys.dev/), [Android: Credential Manager](https://developer.android.com/identity/sign-in/credential-manager), [W3C: Web Authentication](https://www.w3.org/TR/webauthn-2/). ### Questions #### Are passkeys better than OTP? For sign-in, yes. A passkey only answers the website it was made for, so it cannot be phished, and there is no code to send, read or intercept. OTP is still useful to verify a new email or phone, and to recover an account when the passkey is lost. #### Which phones support passkeys? Android 9 and later, iOS 16 and later, and current Chrome, Safari, Edge and Firefox. Synced passkeys are backed up by Google Password Manager, iCloud Keychain or password managers such as 1Password. #### Can I add passkeys to Flutter or React Native? Yes. On Android they go through Credential Manager and on iOS through AuthenticationServices; Flutter and React Native packages wrap both, and your server must check the WebAuthn responses. #### What happens if a user loses their phone? Synced passkeys come back on the new phone through the same Google or Apple account. If not, let them in with an email code and ask them to make a new passkey. ## Push authentication, MFA fatigue and number matching URL: https://signops.dev/blog/push-authentication-number-matching/ What push authentication is, how MFA fatigue attacks beat it, why number matching stops them, and how it compares with an OTP. Push authentication replaces “type the code we sent” with “is this you?” on a phone the user already trusts. Done naively, it has a well-known weakness. Done with number matching and a signed challenge, it is one of the safest ways to approve a login or a payment. ### How push authentication works 1. The user signs in to your app once on their phone. The app makes a key pair; the private key stays in the phone’s secure hardware. 2. When someone signs in on the web, your server sends a push to that phone with the device, the place and the time. 3. The user confirms with Face ID or a fingerprint. The app signs the request with its key, and your server checks the signature. ### The weakness: MFA fatigue If approval is a single “Approve” button, an attacker with the user’s password can send request after request until the user taps it to make the noise stop. This is **MFA fatigue**, or push bombing. It was used in the 2022 Uber breach, where a contractor approved one of many spammed requests. ### The fix: number matching The login screen shows a number, say **47**. The phone shows three numbers and asks which one is on the screen. Only the person looking at that login screen can answer. Spammed requests cannot be approved by accident. Microsoft made number matching mandatory for Authenticator push approvals from **8 May 2023** for this reason. ### Push approval vs OTP | | SMS OTP | Push approval with number matching | | --- | --- | --- | | Can be read out to a fraud caller | Yes | No, there is no code | | SIM swap | Catches it | Not affected | | What it proves | Access to the number | Possession of a phone that signed in, plus a biometric | | Works for signup | Yes | No, needs a phone already signed in | | Cost in India | About Rs 0.13 to Rs 0.25 | Rs 0.02 on [SignOps](https://signops.dev/approve/) past the daily allowance, about Rs 4.75 on Twilio Verify Push | ### What to require from a push approval system - **Number matching on every request**, not optional. - **A biometric or device PIN** before the phone signs. - **A signed, single-use challenge** with a short expiry, so a copied push approves nothing. - **Context on the phone:** device, place, time and, for payments, the amount. - **A fallback** when the push is late, such as fetching pending requests when the app opens and an email code. [SignOps Approve](https://signops.dev/approve/) has all five, with an SDK for Android, iOS, React Native and Flutter. 100 approvals a month are free, and Starter includes 100 a day. Sources: [BleepingComputer: Microsoft enforces number matching](https://www.bleepingcomputer.com/news/microsoft/microsoft-enforces-number-matching-to-fight-mfa-fatigue-attacks/), [SC Media: number matching for all Authenticator push notifications](https://www.scmagazine.com/news/microsoft-enables-number-matching-for-all-authenticator-push-notifications). ### Questions #### What is push authentication? Instead of typing a code, the user approves a sign-in on a phone they already use. The app shows who is signing in and from where, and the user confirms, usually with a biometric. #### What is an MFA fatigue attack? An attacker who has a user's password sends approval request after request until the user taps approve to make it stop. It is also called push bombing, and it was used in the 2022 Uber breach. #### What is number matching? The login screen shows a number and the phone asks the user to pick the same one. Only the person in front of that screen can answer, so spammed requests cannot be approved blindly. Microsoft made it mandatory in Authenticator in May 2023. #### Is push authentication better than OTP? With number matching and a signed challenge, yes for most logins; there is no code to read out to a caller and nothing for a SIM swap to catch. It only works on a phone that already signed in, so OTP or passkeys still cover signup and recovery. ## The RBI's 2025 authentication directions, for developers URL: https://signops.dev/blog/rbi-authentication-directions-2025/ What the RBI's Authentication Mechanisms for Digital Payment Transactions Directions, 2025 ask of apps from 1 April 2026, and the alternatives to SMS OTP they name. On 25 September 2025 the Reserve Bank of India issued the **Authentication Mechanisms for Digital Payment Transactions Directions, 2025**. Payment companies had until **1 April 2026** to comply. The headline for developers: SMS OTP is no longer the default answer, and the RBI names the alternatives it wants explored. ### What the directions say - **Two factors for every digital payment.** All digital payment transactions must be authenticated by at least two distinct factors. - **No single method is required.** Earlier practice leaned on SMS OTP. The 2025 directions do not name one method, which lets banks and payment companies choose. - **Risk-based checks are encouraged.** Signals like behavioural biometrics, location and payment history, digital tokens and **in-app notifications** are listed as alternatives to explore. - **The reason** is the rise of phishing, vishing (fraud calls) and smishing (fraud SMS), which all work by getting the user to hand over an OTP. ### Who it applies to Payment system providers and participants: banks and non-bank payment companies, including wallets, card issuers and payment aggregators. An app that signs users in but does not move money is not covered, though the same methods protect its users too. ### What banks have built - **SBI Authenticator:** approvals by prompt, QR codes, and app-based OTP instead of SMS. - **Axis Bank in-app OTP:** time-based codes made inside the app instead of sent by SMS. Both move the second factor into an app the customer already trusts. ### The alternatives, for a developer | Method | What it is | Phishable | | --- | --- | --- | | In-app push approval | The app shows the payment and asks for a biometric; the phone signs it | No, with number matching | | Passkeys and device biometrics | A key bound to the device and your domain | No | | In-app or authenticator codes | Time-based codes made in the app | Yes, but no SIM swap | | Risk-based checks | Device, location and history decide when to ask for more | Not a factor by itself | | SMS OTP | A code by SMS | Yes | ### Building it [SignOps Approve](https://signops.dev/approve/) is in-app notification approval: the customer’s trusted phone shows the amount and payee, the user matches a number and confirms with Face ID or a fingerprint, and your server gets a signed answer. [Passkeys](https://signops.dev/passkeys/) cover device-bound sign-in. Whether a particular flow meets the directions is for your compliance team to confirm. Sources: [RBI: Authentication Mechanisms for Digital Payment Transactions Directions, 2025](https://rbi.org.in/scriptS/BS_ViewMasDirections.aspx?id=12898), [Entrepreneur India: RBI’s new rules move beyond SMS OTPs](https://india.entrepreneur.com/news-and-trends/rbis-new-rules-for-enhanced-digital-payment-security-move/497665), [Khaitan & Co on the directions](https://khaitanco.com/thought-leadership/RBI-Authentication-Mechanisms-for-Digital-Payments-Transactions-Directions), [Business Standard: Axis Bank in-app OTP](https://www.business-standard.com/amp/finance/personal-finance/axis-bank-starts-in-app-mobile-otp-to-protect-users-from-frauds-scams-125040700498_1.html). ### Questions #### Is the RBI banning SMS OTP? No. The 2025 directions keep the requirement of two factors and stop naming a single method, so banks and payment companies can use SMS OTP alongside or instead of other factors. #### When do the RBI authentication directions apply? The Reserve Bank of India issued them on 25 September 2025, with compliance required by 1 April 2026. #### Do the directions apply to my startup? They apply to payment system providers and participants, which includes banks and non-bank payment companies. Apps that only sign users in, without moving money, are not covered, though the same methods are good practice. ## WebAuthn and passkey errors, and what each one means URL: https://signops.dev/blog/webauthn-errors/ Every error navigator.credentials.create() and get() throw: NotAllowedError, InvalidStateError, SecurityError for rp.id, ConstraintError, with causes and fixes. WebAuthn reports every failure as a `DOMException` with a `name`. The `message` text differs between Chrome, Safari and Firefox, so branch on `err.name` and log the message for yourself. This page lists each name the spec throws from `navigator.credentials.create()` (registering a passkey) and `navigator.credentials.get()` (signing in), what causes it in practice, and what to show the user. ```js try { const cred = await navigator.credentials.create({ publicKey: options }); } catch (err) { console.log(err.name, err.message); // "NotAllowedError", "The operation either timed out or was not allowed..." } ``` ### Every WebAuthn error, at a glance | err.name | create() | get() | Usual cause | What to do | | --- | --- | --- | --- | --- | | `NotAllowedError` | Yes | Yes | Cancelled, timed out, no matching credential, blocked by policy | Show “Try again” and a fallback sign-in. Do not count it as a failed attempt | | `InvalidStateError` | Yes | | A credential in `excludeCredentials` is already on this authenticator | Say the passkey is already set up | | `SecurityError` | Yes | Yes | `rp.id` does not match the page’s domain, or the page is not HTTPS | Fix `rp.id` or serve the page over HTTPS | | `NotSupportedError` | Yes | | None of the algorithms in `pubKeyCredParams` is supported | Include `-7` (ES256) and `-257` (RS256) | | `ConstraintError` | Yes | | `residentKey: "required"` or `userVerification: "required"` and no authenticator can do it | Use `"preferred"` unless you really need it | | `AbortError` | Yes | Yes | Your `AbortSignal` was aborted | Expected when you cancel; ignore it | | `TypeError` | Yes | Yes | Bad options: `user.id` empty or over 64 bytes, a challenge that is not a buffer | Fix the options your server sends | ### NotAllowedError This is the one you will see most, and it covers several causes on purpose. The spec makes the browser answer the same way whether the user cancelled or no credential exists, so a page cannot probe which accounts have passkeys. Causes, in the order you are likely to hit them: 1. **The user cancelled** the browser or system prompt, or pressed Escape. 2. **The timeout ran out.** `timeout` is in milliseconds and is a hint the browser may change. Five minutes (`300000`) is common. 3. **No credential matched (sign-in).** You sent an `allowCredentials` list and none of those ids is on any authenticator the browser can reach: the passkey was deleted, was made on another phone that is not synced, or was made for another `rpId`. 4. **No user gesture.** Safari in particular wants `create()` and `get()` called straight from a click handler. An `await fetch()` before the call can lose the gesture; fetch the options first, then call on the click. 5. **A cross-origin iframe without permission.** The iframe needs `allow="publickey-credentials-create; publickey-credentials-get"`. What to do: show a short message (“Sign-in was cancelled. Try again or use another way”) and keep a fallback visible. Never lock an account over `NotAllowedError`. ### InvalidStateError Only on `create()`. Your server listed the user’s existing credential ids in `excludeCredentials`, which is right, and the authenticator recognised one of them. The user already has a passkey for this account on this device or in this password manager. ```js } catch (err) { if (err.name === "InvalidStateError") return showMessage("You already have a passkey for this account on this device."); throw err; } ``` Keep sending `excludeCredentials`. Without it the same phone would save two passkeys for one account and the user could not tell them apart. ### SecurityError The browser checks that `rp.id` (on `create()`) or `rpId` (on `get()`) is the page’s own domain or a parent domain of it: | Page | rp.id | Result | | --- | --- | --- | | `https://app.example.com` | `example.com` | Works | | `https://app.example.com` | `app.example.com` | Works | | `https://app.example.com` | `login.example.com` | SecurityError | | `https://example.com` | `example.co` | SecurityError | | `http://localhost:3000` | `localhost` | Works | | `http://192.168.1.5:3000` | anything | SecurityError (IP address) | | `http://staging.example.com` | `example.com` | Fails: not a secure context | Two things follow. Pick the parent domain as `rp.id` from the start if you have several subdomains, because passkeys made for `app.example.com` will never work on `www.example.com`. And test on a phone through an HTTPS tunnel or a real domain, not your laptop’s IP. Different domains that must share passkeys need Related Origin Requests, a `/.well-known/webauthn` file listing them. ### NotSupportedError and ConstraintError `NotSupportedError` means the authenticator supports none of the algorithms you offered. Offer both: ```js pubKeyCredParams: [ { type: "public-key", alg: -7 }, // ES256, every passkey provider { type: "public-key", alg: -257 }, // RS256, Windows Hello ], ``` `ConstraintError` means you demanded something no available authenticator can give: a discoverable credential (`residentKey: "required"`) or user verification (`userVerification: "required"`), and the user only has, say, an old security key. `"preferred"` for both works with every passkey and still gives you a discoverable credential with verification on phones and laptops. ### AbortError Your own code aborted the request through the `signal` you passed. The usual case is passkey autofill: a conditional `get()` waits in the background, and when the user clicks a “Sign in with a passkey” button you abort it first, because only one WebAuthn request can run at a time. Catch `AbortError` and do nothing. See [passkey autofill](https://signops.dev/blog/passkey-autofill/). ### Errors from your server, not the browser Some failures pass the browser and fail at verification. They are not `DOMException`s; your server library raises them: - **Challenge mismatch.** The challenge in `clientDataJSON` is not the one you stored, often because two tabs started a sign-in. Store one challenge per attempt and expire it in a few minutes. - **Origin mismatch.** `clientDataJSON.origin` is `https://www.example.com` and you expected `https://example.com`. List every origin your sign-in page is served from. In Android apps the origin is `android:apk-key-hash:...`, not your web domain. - **rpIdHash mismatch.** The credential was made for another `rp.id`. - **Counter went backwards.** Synced passkeys usually report a counter of 0. Accept 0; only treat a lower non-zero counter as suspect. ### What SignOps does with these [SignOps Passkeys](https://signops.dev/passkeys/) makes the options on its server: `excludeCredentials` from the user’s existing passkeys, `residentKey` and `userVerification` set to `"preferred"`, ES256 and RS256 offered, a fresh challenge per attempt, and the origin and `rp.id` checked against the domain and sign-in pages you list in the dashboard. Your page calls `passkeys.register()` or `passkeys.signIn()` from `@signops/web` and gets a token your server confirms. Setup: [passkeys on your website](https://signops.dev/guides/passkeys-web/), or test what your browser supports with the [passkey checker](https://signops.dev/tools/passkey-check/). Sources: [W3C: Web Authentication Level 3, create and get request exceptions](https://www.w3.org/TR/webauthn-3/), [MDN: CredentialsContainer.create()](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create), [MDN: CredentialsContainer.get()](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get). ### Questions #### What does "NotAllowedError The operation either timed out or was not allowed" mean? It is WebAuthn's catch-all. The user closed or cancelled the prompt, the timeout ran out, the page called the API without a user gesture inside a cross-origin iframe, a permissions policy blocked it, or, for a sign-in, none of the credentials in allowCredentials exist on any authenticator the browser can reach. The browser does not tell your page which, on purpose, so a site cannot learn whether a passkey exists. #### Why does navigator.credentials.create() throw InvalidStateError? The authenticator already holds a credential listed in excludeCredentials, so the user tried to register a second passkey for the same account on the same device or password manager. Treat it as "already set up" and show that, not as a failure. #### What causes SecurityError "The relying party ID is not a registrable domain suffix of, nor equal to the current domain"? The rp.id (or rpId) you passed is not the page's domain or a parent of it. A page on app.example.com can use example.com or app.example.com, never another domain. It also happens on an IP address or a non-HTTPS origin other than localhost. #### Does WebAuthn work on localhost? Yes. Browsers treat http://localhost as a secure context, so the API works there with rp.id set to localhost. Any other plain http origin fails, and so does an IP address such as 192.168.1.5. #### What is the difference between NotAllowedError and AbortError? AbortError comes from your own code, when the AbortSignal you passed is aborted, for example when you cancel a pending passkey autofill request to start a button sign-in. NotAllowedError comes from the user or the browser. ## Passkey autofill: show passkeys in the username field URL: https://signops.dev/blog/passkey-autofill/ Set up passkey autofill with mediation conditional: the autocomplete attribute, the feature check, an empty allowCredentials, aborting for the button, and fixes. Passkey autofill puts a user’s passkeys in the same dropdown as saved passwords, on the username field they already tap. There is no extra button to find and no modal that appears before the user asks. The spec calls it **conditional mediation**; browsers and docs also call it conditional UI. It takes three changes to a normal passkey sign-in. ### 1. Mark the username field Add `webauthn` as the last token of the field’s `autocomplete`: ```html ``` `email webauthn` works the same for an email field. Without the `webauthn` token the browser shows passwords only. ### 2. Check support ```js async function autofillSupported() { if (!window.PublicKeyCredential) return false; if (PublicKeyCredential.getClientCapabilities) { const caps = await PublicKeyCredential.getClientCapabilities(); return caps.conditionalGet === true; } return PublicKeyCredential.isConditionalMediationAvailable?.() ?? false; } ``` `getClientCapabilities()` is the newer check; `isConditionalMediationAvailable()` covers browsers that shipped autofill before it. ### 3. Start a conditional get() when the page loads Ask your server for sign-in options with an **empty** `allowCredentials`, then start the request straight away, not on a click: ```js const autofill = new AbortController(); async function startAutofill() { if (!(await autofillSupported())) return; const json = await fetch("/api/passkeys/options").then((r) => r.json()); // fresh challenge, allowCredentials: [] const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(json); try { const cred = await navigator.credentials.get({ publicKey, mediation: "conditional", signal: autofill.signal }); if (!cred) return; await fetch("/api/passkeys/verify", { method: "POST", body: JSON.stringify(cred.toJSON()) }); } catch (err) { if (err.name === "AbortError") return; // we cancelled it if (err.name === "NotAllowedError") return; // the user dismissed it throw err; } } startAutofill(); ``` The promise stays pending, with no UI, until the user focuses the field and picks a passkey. If they pick a saved password instead, the promise never settles and your normal form submit runs. Your server finds the account from the credential id and `userHandle`, because it did not know the user when it made the options. ### The button, and why you must abort first Keep a **Sign in with a passkey** button for browsers without autofill. Only one WebAuthn request runs at a time, so a pending conditional request blocks the button’s call. Abort it first: ```js button.onclick = async () => { autofill.abort(); const json = await fetch("/api/passkeys/options").then((r) => r.json()); const cred = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(json) }); // verify as above }; ``` Abort the same way before a registration starts on the same page, and when a single-page app routes away from the sign-in screen. ### When passkeys do not appear in the list | Symptom | Cause | | --- | --- | | Only passwords in the dropdown | `autocomplete` lacks `webauthn`, or `get()` never ran because it waited for a click | | Nothing at all | The field is not focused; `autofocus` or a tap opens the list | | The user has a passkey but it is missing | `rpId` differs from the `rp.id` used at registration, or the passkey was made non-discoverable (`residentKey: "discouraged"`) | | Works on desktop, not in the app’s web view | Many in-app web views do not support WebAuthn; open the system browser or use the platform’s passkey API | | Second sign-in fails with a challenge error | The page reused the first challenge. Make a fresh one for every `get()` | | The button does nothing | The conditional request is still pending. Abort it before the button’s `get()` | ### The server side - A new random challenge for every request, kept a few minutes, used once. - `allowCredentials: []` for autofill, `userVerification: "preferred"`, and the same `rpId` every time. - Look up the credential by id, check the signature, the challenge, the origin and the `rpIdHash`, then start the session. - If the credential id is unknown (the passkey was deleted on your side), answer 404 and, where the browser supports it, call `PublicKeyCredential.signalUnknownCredential()` so the password manager stops offering it. ### With SignOps [SignOps Passkeys](https://signops.dev/passkeys/) makes the options and checks the answer on its servers. In the browser, `@signops/web` does steps 2 and 3: ```js const { signops_token } = await passkeys.signIn({ autofill: true }); ``` It checks support, starts the conditional request and gives back a token your server confirms with one call. The full flow, from your domain to the server check, is in the [passkeys on your website guide](https://signops.dev/guides/passkeys-web/). If something fails, [WebAuthn errors](https://signops.dev/blog/webauthn-errors/) lists each error name and its cause, and the [passkey checker](https://signops.dev/tools/passkey-check/) shows whether this browser supports autofill. Sources: [web.dev: Create a passkey for passwordless logins with form autofill](https://web.dev/articles/passkey-form-autofill), [MDN: CredentialsContainer.get(), mediation](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get), [W3C: Web Authentication Level 3](https://www.w3.org/TR/webauthn-3/). ### Questions #### What is passkey autofill, or conditional UI? The browser offers the user's saved passkeys in the username field's autofill list, next to saved passwords, instead of in a separate modal. The page starts a navigator.credentials.get() call with mediation set to "conditional" when it loads; the call waits silently until the user picks a passkey from the list. #### Why don't my passkeys show in the autofill list? The usual causes are a missing "webauthn" token in the input's autocomplete attribute, the get() call made with a non-empty allowCredentials list, an rpId that does not match the one the passkey was made with, or the get() call never started because it waited for a click. The field also has to be focused for the list to open. #### Should allowCredentials be empty for passkey autofill? Yes. With conditional mediation the browser lists every discoverable credential for your rpId, so the server does not know the user yet and sends an empty allowCredentials. The passkey itself tells you which user it belongs to through userHandle. #### Can I keep a "Sign in with a passkey" button as well? Yes, and you should, for browsers without autofill support. Only one WebAuthn request can run at a time, so abort the pending conditional request with its AbortController before the button starts its own get(). ## Web push errors, and what to do about each URL: https://signops.dev/blog/web-push-errors/ Every status a web push service returns, from 201 to 410 Gone, 403 for VAPID key problems, 413 payload too large and 429, with what to delete, fix or retry. A web push goes from your server to a **push service** run by the browser maker: FCM for Chrome, Edge and other Chromium browsers, Mozilla’s autopush for Firefox, Apple’s service for Safari. The URL in the subscription’s `endpoint` tells you which. They all follow the same protocol (RFC 8030, with VAPID in RFC 8292), so they answer with the same status codes. Here is each one and what to do. ### Every web push status code | Status | Meaning | What to do | Retry | | --- | --- | --- | --- | | `201 Created` | Accepted by the push service | Nothing. It is not proof of display | | | `400 Bad Request` | A header is wrong: `TTL` missing, a bad `Urgency`, a `Topic` over 32 characters, bad encryption headers | Fix the request | No | | `401 Unauthorized` | No VAPID `Authorization` header | Sign every request | No | | `403 Forbidden` | VAPID is invalid: wrong key pair, bad signature, `exp` out of range, wrong `aud` | See below | No | | `404 Not Found` | The subscription has expired | Delete the subscription | No | | `410 Gone` | The subscription was removed (unsubscribed, permission revoked) | Delete the subscription | No | | `413 Payload Too Large` | The encrypted body is over the service’s limit | Send less | No | | `429 Too Many Requests` | Rate limited | Wait for `Retry-After`, then retry | Yes | | `5xx` | The push service failed | Back off and retry | Yes | ### 404 and 410: delete the subscription The RFC uses 404 for an expired subscription; in practice push services also answer 410 when the user unsubscribed or blocked notifications. Treat both the same way: remove the row and stop sending. A sender that keeps posting to dead endpoints wastes requests and, at volume, gets rate limited. ```js const res = await webpush.sendNotification(sub, payload).catch((e) => e); if (res.statusCode === 404 || res.statusCode === 410) await db.subscriptions.delete(sub.endpoint); ``` To see a 410 yourself, call `registration.pushManager.getSubscription()` in the browser, then `subscription.unsubscribe()`, and send to the old endpoint. ### 403 Forbidden: VAPID problems To check a real header, paste it into the [VAPID key checker](https://signops.dev/tools/vapid-keys/): it verifies the signature, `aud`, `exp`, `sub` and the subscription key in the browser. A subscription is tied to the public key the page passed as `applicationServerKey` in `pushManager.subscribe()`. Every push to it must be signed with the matching private key. The usual ways this breaks: 1. **The keys changed.** Someone ran `web-push generate-vapid-keys` again, or staging and production have different keys and share a database. Every old subscription now returns 403. Keep one key pair per site for ever, or resubscribe every user. 2. **The JWT’s `exp` is wrong.** It must be in the future and at most 24 hours ahead. A server clock that is off, or an `exp` in milliseconds instead of seconds, fails. 3. **`aud` is wrong.** It must be the origin of the endpoint, such as `https://fcm.googleapis.com`, not your site and not the full endpoint URL. 4. **`sub` is missing or malformed.** It must be a `mailto:` address or an `https:` URL. Apple’s service is strict about it. The header looks like this in the current scheme: ```http Authorization: vapid t=, k= TTL: 86400 Content-Encoding: aes128gcm ``` ### 400: the headers - `TTL` is required. It is how many seconds the service keeps the message for an offline device; `0` means deliver now or drop. Services may shorten it and tell you in the response’s `TTL` header. - `Urgency` is `very-low`, `low`, `normal` or `high`. Use `high` for messages the user is waiting for. - `Topic` lets a newer message replace a waiting one with the same topic. At most 32 URL-safe base64 characters. - The body must be encrypted with the subscription’s `p256dh` and `auth` keys (`aes128gcm`). Sending plain JSON fails. ### 413: payload size Push services must accept an encrypted body of at least 4,096 bytes. Encryption adds a header and padding, so keep your JSON under about 3,900 bytes. For more, send an id and let the service worker fetch the rest: ```js self.addEventListener("push", (e) => { const { id, title } = e.data.json(); e.waitUntil(self.registration.showNotification(title, { data: { id } })); }); ``` ### 201, and still nothing on screen The push service accepted it; the rest happens in the browser: - The service worker has no `push` listener, or does not call `showNotification()` inside `event.waitUntil()`. Chrome then shows its own “This site has been updated in the background” notice. - The browser was closed past the `TTL`, or the computer was asleep. - On iPhone and iPad, web push only works for a site added to the Home Screen. See [web push on iOS](https://signops.dev/blog/web-push-ios/). - The user turned notifications off for the browser in the operating system’s settings, or Focus or Do Not Disturb is on. Each of these, with the fix, is in [web push received but not shown](https://signops.dev/blog/web-push-not-showing/). ### With SignOps [SignOps Push](https://signops.dev/push/) makes the VAPID keys for your app and keeps them, so they never change under your subscriptions. It encrypts each message, signs it per push service, removes a subscription on 404 or 410 and posts `device.removed` to your webhook, retries 429 and 5xx, and records any other answer as the push’s failure reason in the dashboard. Setup is in the [web push guide](https://signops.dev/guides/web-push/), the [web push tester](https://signops.dev/tools/web-push-test/) sends a push to this browser with no account, and the [VAPID key checker](https://signops.dev/tools/vapid-keys/) reads your Authorization header and says which part causes a 403. Sources: [IETF RFC 8030: Generic Event Delivery Using HTTP Push](https://datatracker.ietf.org/doc/html/rfc8030), [IETF RFC 8292: VAPID](https://datatracker.ietf.org/doc/html/rfc8292), [web.dev: The Web Push Protocol](https://web.dev/articles/push-notifications-web-push-protocol). ### Questions #### What does 410 Gone mean when sending a web push? The push subscription no longer exists. The user unsubscribed, revoked the notification permission, cleared the site's data, or the browser expired the subscription. Delete it from your database; the browser will make a new one the next time your page subscribes. #### Why does web push return 403 Forbidden? The push service rejected your VAPID authentication. Most often the VAPID key pair you sign with is not the one whose public key the page passed as applicationServerKey when it subscribed, or the JWT's exp is more than 24 hours ahead or already past, or the aud claim is not the push service's origin. #### What is the maximum web push payload size? Push services must accept at least 4,096 bytes of encrypted body. After encryption overhead that leaves roughly 3,990 bytes for your JSON. Anything larger may get 413 Payload Too Large. Send an id and fetch the rest from your server in the service worker if you need more. #### Is a 201 Created response proof the notification was shown? No. 201 means the push service accepted the message. Delivery depends on the device being online before the TTL runs out, and display depends on your service worker calling showNotification in its push event. ## Web push received but not shown URL: https://signops.dev/blog/web-push-not-showing/ Why a web push arrives but no notification shows, or Chrome shows its generic notice: waitUntil, userVisibleOnly, tag and renotify, and OS settings. Your server got `201 Created`, the push reached the browser, and still nothing useful appeared. Either nothing at all, or Chrome’s generic “This site has been updated in the background”. From here on the problem is in the service worker or the device, not the sender. This page goes through each cause in the order to check them. ### Symptom to cause | What you see | Likely cause | Fix | | --- | --- | --- | | “This site has been updated in the background” | No `showNotification()` before `event.waitUntil()` settled | Show a notification on every push, inside `waitUntil` | | Nothing, no error | `showNotification()` called but its promise not passed to `waitUntil` | Pass the promise; the worker can stop before it runs | | Nothing, `TypeError` in the worker console | Permission denied, or the worker is not active yet | Check `Notification.permission`; wait for the worker to activate | | `subscribe()` fails in Chrome or Edge | `userVisibleOnly` missing or `false` | Subscribe with `userVisibleOnly: true` | | A new push replaces the old one without a sound | Same `tag`, `renotify` not set | Add `renotify: true`, or use a different tag | | Works on desktop, not on iPhone | Site not added to the Home Screen | See [web push on iOS](https://signops.dev/blog/web-push-ios/) | | Arrives late or never | Browser closed or computer asleep past the `TTL` | A longer `TTL`, or `Urgency: high` for messages people wait for | ### “This site has been updated in the background” Chrome requires a visible notification for every push. If your `push` handler does not show one before the promise you passed to `event.waitUntil()` settles, Chrome shows its own notice with that text. The fix is always the same shape: ```js self.addEventListener("push", (event) => { const n = event.data ? event.data.json() : {}; event.waitUntil( self.registration.showNotification(n.title || "New message", { body: n.body || "", data: { url: n.url } }) ); }); ``` Three ways code that looks right still triggers it: 1. **A branch that skips the notification.** For example `if (document is focused) return;` logic, or “only show it if the data is new”. Every branch must end in `showNotification()`. 2. **`fetch` first, notification after, without one chain.** If you fetch content and then show it, return the whole chain to `waitUntil`, and show a fallback notification in `catch` so a failed fetch still shows something. 3. **An exception in the handler.** A bad `JSON.parse` on a text payload throws before anything is shown. Wrap it. ```js self.addEventListener("push", (event) => { event.waitUntil((async () => { try { const { id } = event.data.json(); const msg = await fetch(`/api/messages/${id}`).then((r) => r.json()); await self.registration.showNotification(msg.title, { body: msg.body }); } catch { await self.registration.showNotification("You have a new message"); } })()); }); ``` ### Silent pushes: userVisibleOnly `pushManager.subscribe()` takes `userVisibleOnly`, a promise that every push will lead to something the user can see. Chrome and Edge reject the subscription unless it is `true`, so a data-only web push that updates a cache and shows nothing is not possible there. Firefox lets a site receive a limited number of pushes without a notification; pushes that show one are exempt, and the quota refreshes each time the user visits the site. ```js const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }); ``` If you need to sync data in the background, show a notification as part of the same push, or sync the next time the page opens. ### The notification is shown but nobody notices - **`tag` replaces quietly.** A notification with the same `tag` replaces the older one, and by default the replacement makes no sound. Set `renotify: true` with the tag when the user should hear it again. - **`silent: true`** turns off sound and vibration whatever the device settings are. - **It closes on its own.** Use `requireInteraction: true` if it must stay until the user clicks or dismisses it. - **The operating system hides it.** macOS and Windows have their own notification switch per browser, and Focus or Do Not Disturb hides banners. If the [web push tester](https://signops.dev/tools/web-push-test/) cannot show a notification either, the cause is here, not in your code. ### Check the service worker itself - Open DevTools > Application > Service workers. The worker must be activated for your page’s scope, and the Push button there sends it a test push event. - `showNotification()` throws a `TypeError` while the worker is not `activating` or `activated`, and when the user has denied permission. - A worker at `/js/sw.js` only controls `/js/`. Serve it from the root, or set the `Service-Worker-Allowed` header. - After editing the worker, the old one keeps running until every tab using it closes. Tick “Update on reload” while testing. ### With SignOps [SignOps Push](https://signops.dev/push/) sends the title and body you give it in the push, so your worker always has something to show. Its ready-made worker (two lines in your `sw.js`, see the [web push guide](https://signops.dev/guides/web-push/)) calls `showNotification()` inside `waitUntil` for every push that has a title or body, draws the image and up to two buttons, and opens the link on click. A push you send with neither is left to your own code, which on Chrome must still show something. The `@signops/web` library subscribes with `userVisibleOnly: true`. To rule out your server, the [web push tester](https://signops.dev/tools/web-push-test/) sends a push to this browser with no account, and [web push errors](https://signops.dev/blog/web-push-errors/) covers the status codes before the push reaches the browser. Sources: [web.dev: Push events](https://web.dev/articles/push-notifications-handling-messages), [MDN: PushManager.subscribe()](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe), [MDN: Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API), [MDN: showNotification()](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification). ### Questions #### Why does Chrome say "This site has been updated in the background"? Your service worker received a push but did not show a notification before the promise passed to event.waitUntil() settled. Chrome then shows its own generic notice instead. Call self.registration.showNotification() inside event.waitUntil() on every push. #### Can I send a web push without showing a notification? Not in Chrome or Edge. Both reject pushManager.subscribe() unless userVisibleOnly is true, which is a promise to show a notification for every push. Firefox allows a limited number of pushes without a notification, and the quota refreshes each time the user visits the site. #### The push arrives in the service worker but nothing appears. Why? The usual causes are a showNotification call outside event.waitUntil(), a promise that is not returned so the worker stops early, a tag that replaces an older notification quietly, notifications turned off for the browser in the operating system, or Focus or Do Not Disturb. #### Why does a second notification with the same tag not alert? A notification with the same tag replaces the older one, and by default the replacement makes no sound or vibration. Set renotify to true, together with the tag, if the user should be alerted again. ## Web push on iPhone and iPad URL: https://signops.dev/blog/web-push-ios/ What iOS needs for web push: version 16.4, a Home Screen web app, a standalone manifest and a prompt from a tap. With detection code and a service worker. iPhones and iPads get web push for sites that the user has added to the Home Screen, since iOS and iPadOS 16.4. It is the same Push API, Notifications API and service worker that Chrome and Firefox use, with three extra conditions. Most “web push does not work on iOS” reports are one of them. ### The three conditions 1. **iOS or iPadOS 16.4 or later.** 2. **Opened from the Home Screen.** The user taps Share, then Add to Home Screen, and opens the site from that icon. In a Safari tab the page has no `PushManager` at all. 3. **A manifest with `display` set to `standalone` or `fullscreen`.** Without it, the Home Screen icon opens a Safari tab and condition 2 fails. And one rule for the prompt: `Notification.requestPermission()` must run from a tap. On page load it is ignored. ### The manifest ```json { "name": "Example", "short_name": "Example", "id": "/", "start_url": "/", "display": "standalone", "icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }] } ``` ```html ``` The `id` matters on iOS: it identifies the installed web app, and Focus settings follow it across the user’s devices. ### Detect, then guide the user Detect features, not browsers. On an iPhone without `PushManager`, the useful answer is a short “add to Home Screen” hint: ```js const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); const standalone = window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true; if ("PushManager" in window && "serviceWorker" in navigator) { showEnableButton(); } else if (isIOS && !standalone) { showHint("To get notifications, tap Share, then Add to Home Screen, and open the app from there."); } ``` ### Ask from a tap ```js enableButton.addEventListener("click", async () => { const permission = await Notification.requestPermission(); // first line in the handler if (permission !== "granted") return; const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC_KEY }); await fetch("/api/push/subscribe", { method: "POST", body: JSON.stringify(sub) }); }); ``` Fetch anything you need, such as the VAPID key, before the tap. An `await fetch()` between the tap and `requestPermission()` can lose the gesture. ### The service worker ```js self.addEventListener("push", (event) => { const { title, body, url } = event.data.json(); event.waitUntil(self.registration.showNotification(title, { body, data: { url } })); }); self.addEventListener("notificationclick", (event) => { event.notification.close(); event.waitUntil(clients.openWindow(event.notification.data.url || "/")); }); ``` Always show a notification for every push. Safari expects each push to produce one (`userVisibleOnly: true`), and silent pushes are not supported. ### Badges iOS 16.4 also added the Badging API for Home Screen web apps: `navigator.setAppBadge(3)` and `navigator.clearAppBadge()`, from the page or from the service worker’s `push` handler. ### Sending The subscription’s `endpoint` is on `web.push.apple.com`. Send to it exactly as to Chrome’s or Firefox’s: an encrypted body, a VAPID `Authorization` header, a `TTL`. If your server has an allow list for outgoing requests, add `*.push.apple.com`. Apple is strict about the VAPID `sub` claim: give a real `mailto:` or `https:` value. A 403 means the VAPID header failed; a 404 or 410 means the subscription is gone. All the codes are in [web push errors](https://signops.dev/blog/web-push-errors/). ### Things that still differ from Android and desktop - Each Home Screen install has its own permission and subscription, separate from Safari. - If the user removes the icon, the subscription goes with it; your next send gets a 410. - In the EU, Apple’s iOS 17.4 changes briefly removed Home Screen web apps, then restored them; test on current versions before relying on any one behaviour. ### With SignOps [SignOps Push](https://signops.dev/push/) sends web pushes to Safari, Chrome, Edge and Firefox with VAPID keys it makes for your app, removes subscriptions that answer 404 or 410, and sends to the user’s Android and iOS apps from the same call. The [web push guide](https://signops.dev/guides/web-push/) has the iPhone steps, the [web push tester](https://signops.dev/tools/web-push-test/) sends a test push to the device you open it on, and the [VAPID key checker](https://signops.dev/tools/vapid-keys/) finds the cause of a 403 from Apple. Sources: [WebKit: Web Push for Web Apps on iOS and iPadOS](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/), [MDN: Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API), [MDN: Badging API](https://developer.mozilla.org/en-US/docs/Web/API/Badging_API). ### Questions #### Does web push work on iPhone? Yes, on iOS and iPadOS 16.4 and later, for a site the user has added to the Home Screen and opened from that icon. In a normal Safari tab, and in Chrome or Firefox on iPhone, the Push API is not offered to the page, so there is nothing to subscribe. #### Why is PushManager undefined on iPhone? The page is open in a Safari tab, not from the Home Screen, or the device runs a version older than iOS 16.4. Detect the feature with "PushManager" in window and, if it is missing on an iPhone, show the user how to add the site to the Home Screen. #### Do I need an Apple Developer account for web push on iOS? No. Web push on iOS uses the same standard Push API and VAPID keys as Chrome and Firefox. You do not need an Apple Developer Program membership or an APNs key. Allow your server to reach push endpoints on *.push.apple.com. #### Why does Notification.requestPermission() do nothing on iPhone? Safari only shows the prompt when the call comes straight from a user action such as a tap on a button. Calling it on page load, or after an await that loses the gesture, is ignored. ## The Android 13 notification permission, POST_NOTIFICATIONS URL: https://signops.dev/blog/android-notification-permission/ Notifications are off by default on Android 13 and later. How to declare and ask for POST_NOTIFICATIONS in context, handle two denials, and test it with adb. Since Android 13 (API level 33), showing a notification needs a runtime permission, `POST_NOTIFICATIONS`, like the camera or location. New installs start with notifications **off**. If your FCM sends return success and nothing appears on newer phones, this is the first thing to check. ### What changes, by target SDK | Your app targets | On an Android 13+ phone | | --- | --- | | API 33 or higher | You decide when to ask. Until the user allows, nothing shows | | API 32 or lower | The system asks by itself the first time an activity starts after you create a notification channel, usually at launch. One “Don’t allow” and it never asks again until a reinstall or an update that targets 33 | | Any, updated from before the phone moved to 13 | The permission is granted automatically if the app had a channel and the user had not turned notifications off | Google Play requires new apps and updates to target a recent API level, so in practice you are in the first row and the asking is your job. ### 1. Declare it ```xml ``` ### 2. Ask in context Ask when the user does something that makes notifications obviously useful: turns on order updates, follows someone, books something. A prompt on first launch, before the user knows the app, gets denied more often, and two denials end the conversation. ```kotlin class OrderActivity : ComponentActivity() { private val askNotifications = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (!granted) showSettingsHint() } private fun onTrackOrderTapped() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return when { ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED -> Unit shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> showWhyDialog { askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) } else -> askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) } } } ``` `shouldShowRequestPermissionRationale` returns true after one denial. That is the moment to explain in a sentence what the user will get, then ask once more. In Compose, the same contract works through `rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission())`. In React Native call `PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS)`; in Expo, `Notifications.requestPermissionsAsync()`; in Flutter with firebase_messaging, `FirebaseMessaging.instance.requestPermission()`. The rule about asking in context is the same. ### 3. After a second “Don’t allow” The dialog will not appear again. Send the user to the app’s notification settings instead: ```kotlin fun openNotificationSettings(context: Context) { val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) context.startActivity(intent) } ``` ### 4. Know who can see notifications Data messages still arrive with the permission denied, so a valid FCM token does not mean a visible notification. Check before you send something that must be seen, and tell your server: ```kotlin val canShow = NotificationManagerCompat.from(context).areNotificationsEnabled() ``` A user can also allow notifications but switch off one channel. `NotificationManagerCompat.getNotificationChannelCompat(id)?.importance == IMPORTANCE_NONE` tells you that. ### What is exempt - Notifications from media sessions (playback controls). - Apps that manage their own phone calls, for `CallStyle` notifications. - Foreground services can start without the permission; their notification then shows in the Task Manager, not the notification drawer. ### Test each case with adb ```bash ## a fresh install on Android 13+ adb shell pm revoke com.example.app android.permission.POST_NOTIFICATIONS adb shell pm clear-permission-flags com.example.app android.permission.POST_NOTIFICATIONS user-set adb shell pm clear-permission-flags com.example.app android.permission.POST_NOTIFICATIONS user-fixed ## the user allowed it adb shell pm grant com.example.app android.permission.POST_NOTIFICATIONS ``` ### Still missing after the permission? The permission is one of several reasons. A missing channel, a data-only message to a killed app, Doze, and the battery savers on Xiaomi, Oppo and Vivo are the others: [FCM says success but the notification never arrives](https://signops.dev/blog/fcm-notification-not-received/) and [push on Xiaomi, Oppo, Vivo and Realme](https://signops.dev/blog/push-notifications-xiaomi-oppo-vivo/). [SignOps Push](https://signops.dev/push/) sends to the tokens you register and removes the ones Google calls dead; the [Android setup guide](https://signops.dev/guides/android-push/) covers the permission step in the app. Sources: [Android Developers: Notification runtime permission](https://developer.android.com/develop/ui/views/notifications/notification-permission), [Android Developers: Request runtime permissions](https://developer.android.com/training/permissions/requesting). ### Questions #### Do I need POST_NOTIFICATIONS permission on Android 13? Yes, for apps that target Android 13 (API 33) or higher. Declare android.permission.POST_NOTIFICATIONS in the manifest and request it at runtime. On a fresh install on Android 13 or later, notifications are off until the user allows them, and an FCM message with a notification block is received but not shown. #### What happens if the user taps "Don't allow" twice? As with other runtime permissions, after the user denies it twice the system stops showing the dialog and the request returns denied at once. The only way back is the app's notification settings, which you can open with Settings.ACTION_APP_NOTIFICATION_SETTINGS. #### My app targets API 32. Does Android 13 still ask? Yes. The system shows the dialog the first time the app starts an activity after creating a notification channel, usually at launch, so you cannot choose the moment. If the user taps Don't allow even once, they are not asked again until they reinstall or you update the app to target Android 13. #### Do FCM tokens still work if notification permission is denied? Yes. The token is issued and data messages still reach your onMessageReceived. Only showing notifications is blocked. Check NotificationManagerCompat.areNotificationsEnabled() before counting a device as reachable. ## Send a test push to an iPhone with curl URL: https://signops.dev/blog/apns-curl/ Make an APNs JWT from your .p8 key with openssl and send an alert or a background push with curl over HTTP/2, with every header and the errors you may see. Before you write server code, check the key, the token and the bundle id with one `curl` call. If this works, anything that fails later is in your code; if it fails, the reason APNs returns tells you which of the three is wrong. These commands follow Apple’s own guide to sending pushes from the command line. ### What you need | Value | Where to find it | | --- | --- | | `.p8` key file | Apple Developer > Certificates, Identifiers & Profiles > Keys, a key with Apple Push Notifications service enabled. It downloads once | | Key ID | 10 characters, on the key’s page and in the file name `AuthKey_.p8` | | Team ID | 10 characters, top right of the developer account, or Membership details | | Bundle id | The app’s identifier in Xcode, such as `com.example.app`; used as `apns-topic` | | Device token | Hex string from `didRegisterForRemoteNotificationsWithDeviceToken` in the app | The [iOS setup guide](https://signops.dev/guides/ios-push/) shows each of these screens. ### 1. Set the variables ```bash TEAM_ID=ABCDE12345 AUTH_KEY_ID=XYZ9876543 TOKEN_KEY_FILE_NAME=./AuthKey_XYZ9876543.p8 TOPIC=com.example.app DEVICE_TOKEN=4f2a...e91c APNS_HOST_NAME=api.sandbox.push.apple.com # api.push.apple.com for TestFlight and App Store builds ``` ### 2. Make the JWT The token is a JWT signed with ES256: a header with your Key ID, claims with your Team ID and the time it was made. ```bash JWT_ISSUE_TIME=$(date +%s) JWT_HEADER=$(printf '{ "alg": "ES256", "kid": "%s" }' "${AUTH_KEY_ID}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =) JWT_CLAIMS=$(printf '{ "iss": "%s", "iat": %d }' "${TEAM_ID}" "${JWT_ISSUE_TIME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =) JWT_HEADER_CLAIMS="${JWT_HEADER}.${JWT_CLAIMS}" JWT_SIGNED_HEADER_CLAIMS=$(printf "${JWT_HEADER_CLAIMS}" | openssl dgst -binary -sha256 -sign "${TOKEN_KEY_FILE_NAME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =) AUTHENTICATION_TOKEN="${JWT_HEADER}.${JWT_CLAIMS}.${JWT_SIGNED_HEADER_CLAIMS}" ``` The token is good for an hour. Reuse it for every test in that hour; Apple refuses a new one more often than every 20 minutes. ### 3. Send ```bash curl -v --http2 \ --header "authorization: bearer ${AUTHENTICATION_TOKEN}" \ --header "apns-topic: ${TOPIC}" \ --header "apns-push-type: alert" \ --header "apns-priority: 10" \ --data '{"aps":{"alert":{"title":"Test","body":"Sent with curl"},"sound":"default"}}' \ "https://${APNS_HOST_NAME}/3/device/${DEVICE_TOKEN}" ``` A `200` with an empty body and an `apns-id` header means Apple accepted it. The banner shows if the app is in the background; in the foreground the app decides through `userNotificationCenter(_:willPresent:)`. ### A silent (background) push ```bash curl -v --http2 \ --header "authorization: bearer ${AUTHENTICATION_TOKEN}" \ --header "apns-topic: ${TOPIC}" \ --header "apns-push-type: background" \ --header "apns-priority: 5" \ --data '{"aps":{"content-available":1},"order_id":"88213"}' \ "https://${APNS_HOST_NAME}/3/device/${DEVICE_TOKEN}" ``` Background pushes must use priority 5, and the app needs the Remote notifications background mode. iOS limits how many it wakes the app for, so a silent push is never a guaranteed delivery. ### The headers | Header | Value | | --- | --- | | `authorization` | `bearer ` | | `apns-topic` | The bundle id; add `.voip`, `.complication` or `.push-type.liveactivity` for those types | | `apns-push-type` | `alert`, `background`, `voip`, `liveactivity`, `location`, `complication`, `fileprovider`, `mdm`. Must match the payload | | `apns-priority` | `10` now, `5` when power allows, `1` never wake the device. Default 10 | | `apns-expiration` | Unix seconds until which APNs keeps trying; `0` means one attempt, not stored | | `apns-collapse-id` | Up to 64 bytes; a newer push with the same id replaces the older one | The payload is at most 4,096 bytes (5,120 for VoIP). Port 2197 works instead of 443 if your firewall needs it. ### When it fails APNs answers with a JSON `reason`. The ones a curl test usually hits: | Response | Meaning | | --- | --- | | `400 BadDeviceToken` | Sandbox token sent to production or the reverse, or the token has spaces or `<>` | | `400 DeviceTokenNotForTopic` | `apns-topic` is not the app’s bundle id | | `403 InvalidProviderToken` | Key ID or Team ID wrong, or the key was revoked | | `403 ExpiredProviderToken` | The JWT is over an hour old; make a new one | | `400 MissingTopic` | No `apns-topic` header | | `410 Unregistered` | The app was deleted from the phone | Every reason, with the fix and whether to retry, is in [APNs error codes](https://signops.dev/blog/apns-error-codes/). ### From curl to your server The same three steps in code: make the JWT once and cache it for about 50 minutes, keep one HTTP/2 connection open, and read the `reason` of every failure to delete dead tokens. [SignOps Push](https://signops.dev/push/) does that with your `.p8` key: it tries production and falls back to sandbox for development tokens, removes tokens Apple calls dead, retries 429 and 5xx, and sends to Android and the web from the same call. Sources: [Apple: Sending push notifications using command-line tools](https://developer.apple.com/documentation/usernotifications/sending-push-notifications-using-command-line-tools), [Apple: Sending notification requests to APNs](https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns), [Apple: Establishing a token-based connection to APNs](https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns). ### Questions #### How do I send a test push notification to an iPhone from the terminal? Make a JWT from your APNs .p8 key with openssl (header with alg ES256 and your Key ID, claims with your Team ID and the time), then POST the payload with curl --http2 to api.sandbox.push.apple.com at the path /3/device/ followed by the device token, with the authorization, apns-topic and apns-push-type headers. A 200 with an empty body means APNs accepted it. #### Should I use api.sandbox.push.apple.com or api.push.apple.com? A build you run from Xcode gets a sandbox (development) token, so use api.sandbox.push.apple.com. TestFlight and App Store builds get production tokens, which only work with api.push.apple.com. Sending to the wrong one returns 400 BadDeviceToken. #### Do I need a certificate to send APNs pushes with curl? No. A token-based .p8 key works for every app on your team, in both environments, and does not expire each year like a .p12 certificate. Apple also documents the certificate route, with --cert and --key, if you already have one. #### Why does curl say the HTTP/2 protocol is not supported? APNs only speaks HTTP/2. Your curl must be built with HTTP/2 support (nghttp2). The curl that ships with current macOS has it; on older Linux distributions install a newer curl or check curl --version for HTTP2 in the Features line. ## Expo push notification errors, and what to do about each URL: https://signops.dev/blog/expo-push-errors/ Every Expo push ticket, receipt and request error: DeviceNotRegistered, MessageTooBig, MismatchSenderId, InvalidCredentials, TOO_MANY_REQUESTS, with fixes. Expo’s push service answers in two stages. The **ticket** comes back at once and says whether Expo accepted the message. The **receipt**, fetched later with the ticket’s id, says what Apple or Google did with it. Errors can appear in either, and a whole request can also fail. Here is every code Expo documents, and what to do about each. ### Request errors (the whole call failed) These come back in the response’s top-level `errors` array. | Code | Cause | Fix | | --- | --- | --- | | `PUSH_TOO_MANY_NOTIFICATIONS` | More than 100 messages in one request | Send in batches of 100 or fewer | | `TOO_MANY_REQUESTS` | Over 600 notifications a second for the project | Throttle; the Node SDK does this | | `PUSH_TOO_MANY_EXPERIENCE_IDS` | Tokens from more than one Expo project in one request | Group tokens by project; `details` lists which belong where | | `PUSH_TOO_MANY_RECEIPTS` | More than 1,000 ids in one receipts request | Ask for 1,000 or fewer at a time | | `UNAUTHORIZED` | Push security is on and no valid access token was sent | Send `Authorization: Bearer `, or `accessToken` in expo-server-sdk 3.6.0 and later | ### Ticket errors A ticket with `"status": "error"` failed before leaving Expo. | details.error | Meaning | Fix | | --- | --- | --- | | `DeviceNotRegistered` | The device can no longer get pushes | Delete the token | ### Receipt errors Fetch receipts with the ticket ids: ```js const res = await fetch("https://exp.host/--/api/v2/push/getReceipts", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids: ticketIds }), }); const { data } = await res.json(); for (const [id, r] of Object.entries(data)) { if (r.status === "error") console.log(id, r.details?.error, r.message); } ``` | details.error | Meaning | Fix | | --- | --- | --- | | `DeviceNotRegistered` | Apple or Google says the device is unregistered: app uninstalled, notifications revoked | Delete the token until the app registers again | | `MessageTooBig` | The payload is over 4,096 bytes | Send less: an id, then fetch the rest in the app | | `MessageRateExceeded` | Too many messages to one device in a short time | Back off exponentially and send more slowly to that device | | `MismatchSenderId` | The FCM credentials and the `google-services.json` sender id are from different Firebase projects | Use one Firebase project for both | | `InvalidCredentials` | Push credentials for your standalone app are invalid or revoked | Android: upload the FCM V1 service account key again. iOS: `eas credentials`; rebuild if the message mentions `InvalidProviderToken` | Receipts are kept 24 hours. Expo suggests checking them about 15 minutes after sending; a receipt still missing then points to a problem on Expo’s side. ### A sender that handles all of it With the official Node SDK, chunking, throttling and receipts look like this: ```js import { Expo } from "expo-server-sdk"; const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN }); const messages = tokens.filter(Expo.isExpoPushToken).map((to) => ({ to, title, body, data })); const tickets = []; for (const chunk of expo.chunkPushNotifications(messages)) tickets.push(...(await expo.sendPushNotificationsAsync(chunk))); const dead = []; tickets.forEach((t, i) => { if (t.status === "error" && t.details?.error === "DeviceNotRegistered") dead.push(messages[i].to); }); // 15 minutes later, from a job: const ids = tickets.filter((t) => t.status === "ok").map((t) => t.id); for (const chunk of expo.chunkPushNotificationReceiptIds(ids)) { const receipts = await expo.getPushNotificationReceiptsAsync(chunk); // handle DeviceNotRegistered, MessageTooBig, InvalidCredentials as in the table } ``` Keep the token with each ticket id when you store them, so a receipt error can point back to the token to delete. ### When there is no error at all Ticket ok, receipt ok, nothing on the phone: Apple or Google accepted it and the device did not show it. On Android that is usually the notification permission on Android 13 and later, a missing channel, or a phone maker’s battery saver. See [Expo push notifications not working](https://signops.dev/blog/expo-push-notifications-not-working/), [the Android 13 notification permission](https://signops.dev/blog/android-notification-permission/) and [push on Xiaomi, Oppo and Vivo](https://signops.dev/blog/push-notifications-xiaomi-oppo-vivo/). ### Sending without Expo’s service Expo’s service is free and works well for Expo apps. If you want one sender for Expo, bare React Native, Flutter, native apps and the web, [SignOps Push](https://signops.dev/push/) takes your FCM and APNs credentials directly, answers a push to one user with how many of their devices it reached and how many failed, with no receipts to fetch, and removes dead tokens itself. Setup: [push in React Native, Expo and Flutter](https://signops.dev/guides/react-native-flutter-push/). Sources: [Expo: Send notifications with the Expo Push Service](https://docs.expo.dev/push-notifications/sending-notifications/), [expo-server-sdk-node](https://github.com/expo/expo-server-sdk-node). ### Questions #### What does DeviceNotRegistered mean in Expo push? Apple or Google says the device can no longer receive pushes for your app, usually because the app was uninstalled or notifications were revoked. Stop sending to that Expo push token and delete it, until the app registers again and sends you a token. #### How do I fix InvalidCredentials in Expo push notifications? The push credentials Expo holds for your standalone app are invalid or revoked. On Android, upload your FCM V1 service account key again under the project's credentials. On iOS, run eas credentials to regenerate the push key; if the details mention InvalidProviderToken, a rebuild may be needed. #### Why does the Expo push ticket say ok but the notification never arrives? A ticket only means Expo accepted the message. Errors from Apple and Google appear in the push receipt, which you fetch with the ticket id from /--/api/v2/push/getReceipts. Check receipts about 15 minutes after sending; Expo clears them after 24 hours. #### How many push notifications can I send to Expo at once? Up to 100 messages per request and 600 notifications a second per project. Over 100 in one request returns PUSH_TOO_MANY_NOTIFICATIONS; over 600 a second returns TOO_MANY_REQUESTS. The expo-server-sdk for Node chunks and throttles for you. ## Why your server rejects correct authenticator codes URL: https://signops.dev/blog/totp-code-invalid/ Authenticator app codes rejected by your server: clock drift, base32 secrets, leading zeros, ignored parameters and reused codes, with a TOTP check in Node. A user scans your QR code, types the six digits, and your server says the code is wrong. The code is almost never wrong. One of a handful of mismatches between your server and the authenticator app is. Here they are in the order to check them, then a short reference implementation. ### How a TOTP code is made Both sides compute the same thing (RFC 6238): 1. Take the current Unix time in seconds and divide by 30, rounding down. That is the **time step**. 2. HMAC-SHA1 the step, as an 8-byte big-endian number, with the shared secret as the key. 3. Take 4 bytes from the HMAC at an offset given by its last byte, drop the top bit, and keep the last 6 decimal digits. Anything that changes one input on one side changes the code. ### 1. The clock A step is 30 seconds. If the server’s clock and the phone’s differ by more than that, the codes differ. - Check the server: `date -u` against a reliable clock. Containers and VMs drift when NTP is off. `timedatectl` shows whether it is synchronised. - Accept one step either side of now. That covers a phone a little off and a user who reads the code at second 29 and types it at second 33. - Phones set to manual time are the other cause. Google Authenticator on Android had a “Time correction for codes” option in older versions; today the fix is turning on automatic date and time. ### 2. The secret encoding The secret in the `otpauth://` URI is **base32** (A to Z and 2 to 7). Your server must decode it to bytes before the HMAC. A common bug hashes the base32 string’s own characters as the key, which yields codes no app will ever match. ```plaintext otpauth://totp/Example:asha@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example ``` Base32 is also case-insensitive and some apps show it in groups of four with spaces. Upper-case it and strip spaces and `=` before decoding when users type it by hand. ### 3. Leading zeros `012345` is a valid code. Stored or compared as an integer it becomes `12345` and never matches. Keep codes as strings and pad the computed one to six digits. ### 4. Parameters the app ignores The URI can carry `algorithm=SHA256`, `digits=8` and `period=60`. Google Authenticator and several other apps ignore them and always make 6-digit SHA-1 codes every 30 seconds. If your server honours the parameters and the app does not, every code fails. Keep the defaults. ### 5. A code that already worked If the user signs in, signs out and types the same code again within 30 seconds, a correct server **rejects** it: RFC 6238 says a code must not be accepted twice. Store the last step that matched and refuse that step and every earlier one. Tell the user to wait for the next code rather than calling it invalid. ### 6. Enrolment never confirmed If you save the secret when the QR code is shown, and the user scans a different QR code later (or scans nothing), the app and the server hold different secrets. Save the secret as pending, ask for one code to confirm, and only then switch two-step on. ### A reference check in Node ```js import crypto from "node:crypto"; const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; function base32Decode(s) { let bits = 0, value = 0; const out = []; for (const ch of s.toUpperCase().replace(/[\s=]/g, "")) { value = (value << 5) | B32.indexOf(ch); bits += 5; if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; } } return Buffer.from(out); } function codeAt(secret, step) { const msg = Buffer.alloc(8); msg.writeBigUInt64BE(BigInt(step)); const h = crypto.createHmac("sha1", secret).update(msg).digest(); const o = h[h.length - 1] & 15; const n = ((h[o] & 127) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3]; return String(n % 1_000_000).padStart(6, "0"); } // Returns the matched step, or null. Store it and pass it as lastStep next time. export function check(secretB32, code, lastStep = -1, now = Date.now()) { const secret = base32Decode(secretB32); const step = Math.floor(now / 1000 / 30); for (const s of [step - 1, step, step + 1]) { if (s <= lastStep) continue; if (crypto.timingSafeEqual(Buffer.from(codeAt(secret, s)), Buffer.from(String(code).padStart(6, "0")))) return s; } return null; } ``` Test it against the RFC’s own vectors, or against your phone with the [TOTP code checker](https://signops.dev/tools/totp/): it makes a secret and its QR code, shows the live codes, and says which time step a code matches. ### Also needed in production - **Rate limiting per user.** Six digits are a million guesses; without a lock, a bot gets through. Lock after a few failures and grow the lock each time. - **Backup codes**, shown once, stored hashed, each usable once, for a lost phone. - **The secret encrypted at rest.** It has to be read back to check codes, so it cannot be hashed like a password. ### With SignOps [SignOps Authenticator](https://signops.dev/authenticator/) does all of the above on its servers: base32 secrets and the QR code, one step either side of now, each code accepted once, ten backup codes, a per-user lock after five wrong tries, and secrets encrypted at rest. Your server makes two calls, one to enrol and one to check. The [authenticator setup guide](https://signops.dev/guides/authenticator/) builds the settings page and the sign-in step. Sources: [IETF RFC 6238: TOTP](https://datatracker.ietf.org/doc/html/rfc6238), [IETF RFC 4226: HOTP](https://datatracker.ietf.org/doc/html/rfc4226), [Google Authenticator wiki: Key URI format](https://github.com/google/google-authenticator/wiki/Key-Uri-Format). ### Questions #### Why does my server reject a correct Google Authenticator code? Most often the server's clock or the phone's clock is off by more than one 30-second step, the server only accepts the current step, the secret was decoded as plain text instead of base32, or the code was compared as a number and lost a leading zero. Check the server clock first, accept one step either side, and decode the secret as base32. #### How many time steps should a TOTP server accept? RFC 6238 recommends allowing at most one time step of network delay. Most servers accept the previous, current and next 30-second step, which covers a phone clock up to about 30 seconds off and a user who types slowly. A wider window makes guessing easier. #### Can the same TOTP code be used twice? It must not be. RFC 6238 says the verifier must not accept a second attempt with the same code after a successful check. Store the last time step that succeeded for each user and reject any code from that step or earlier. #### Do authenticator apps support SHA-256 or 8-digit codes? Not reliably. Google Authenticator and several others ignore the algorithm, digits and period parameters in the otpauth URI and always make 6-digit SHA-1 codes every 30 seconds. Use those defaults unless you control the app.