Skip to content
Docs

SignOps API

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.

  1. Get an API key. Sign up and copy the secret key from Settings. Keep it on your server only.
  2. Connect push. In Settings, upload your Firebase service account JSON, and your APNs .p8 key if you send to iOS directly.
  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.
register.ts
// 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",
});
send.ts
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",
});

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.

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.

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

Errors

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

{ "error": { "code": "expired", "message": "Code otp_3kd92 expired at 10:12:51 IST." } }
StatusCodeMeaning
400invalid_requestA field is missing or has the wrong type. The message names it.
401invalid_keyThe API key is missing, wrong or revoked.
403not_allowedThe key is a test key used on live data, or the plan does not include it.
404not_foundNo such user, device, message or request on this app.
409already_doneThe code was already verified, or the request already answered.
410expiredThe code or request expired.
422too_many_triesFive wrong codes. Send a new one.
429rate_limitedToo many requests. Wait the seconds in Retry-After.
500server_errorOur fault. Retrying with the same Idempotency-Key is safe.

Libraries and SDKs

Server

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

App

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

Web

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

Login systems

Guides for Supabase Auth, Better Auth, Firebase Auth, Auth0 and Amazon Cognito.

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/devicesAdd a device for a user
GET/v1/users/:user_id/devicesList a user's devices
DELETE/v1/devices/:idRemove a device
POST/v1/devices/importImport tokens from CSV or JSON

Tokens Google, Apple or a browser reject are removed, and a device.removed event tells your server. To move from OneSignal, export subscribers with their external user ids and post them to /v1/devices/import.

Push

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/pushSend now or at send_at
GET/v1/push/:idOne message and its results
DELETE/v1/push/:idCancel a scheduled message
GET/v1/pushList messages, newest first
titleThe notification title.
bodyThe text under it.
dataKey and value pairs your app reads. With no title, the push is silent.
imageAn https image URL for rich notifications.
linkA deep link or URL opened on tap.
buttonsUp to two, each a label and a link.
templateA template id from the dashboard, with fields to fill it.
send_atAn ISO time with a zone, up to 30 days ahead.
marketingtrue applies the app's daily cap per user.
offer.ts
// 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",
});

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/subscribeAdd a user to a topic
POST/v1/topics/:name/unsubscribeTake a user out of a topic
GET/v1/topicsList topics and their sizes

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/approveCreate an approval request
GET/v1/approve/:idRead a request's status
POST/v1/approve/:id/cancelCancel a pending request
POST/v1/approve/devices/enrollSDK: enrol a device's public key
GET/v1/approve/pendingSDK: requests waiting for this device
POST/v1/approve/:id/respondSDK: 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.

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

Passkeys

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-tokenToken for the browser to make a passkey
POST/v1/passkeys/verifyConfirm a sign-in result on your server
GET/v1/users/:user_id/passkeysList a user's passkeys
DELETE/v1/passkeys/:idRemove a passkey
passkeys.ts
import { passkeys } from "@signops/web";

// Register: the token comes from your server.
await passkeys.register({
  user_id: "usr_8f21c4",
  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.

Email OTP

Send makes a 6-digit code, stores its hash and emails it. Verify answers valid: true once. Pass language: "hi" for Hindi. On Pro, set your verified domain in Settings and codes go from your own address.

POST/v1/otp/sendMake and email a code
POST/v1/otp/verifyCheck a code
GET/v1/otp/:idStatus of a code: sent, verified, expired
otp.ts
// Send: we make the code, hash it and email it.
const { otp_id } = await signops.otp.send({
  email: "ananya@example.in",
  app_name: "Kirana Express",
});

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

On the shared domain, codes@mail.signops.dev, the email holds only the code and your app_name, so no link can be sent through it.

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.

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

EventWhen
push.sentA message was handed to FCM, APNs or the browser's push service.
push.failedA device rejected a message. Carries the reason.
push.openedThe user opened the push. Needs the SDK in the app.
device.removedA token was removed because Google, Apple or the browser rejected it.
approve.createdAn approval request was sent to the user's trusted devices.
approve.approvedThe user matched the number and confirmed with a biometric.
approve.deniedThe user said it was not them.
approve.expiredNobody answered in 2 minutes. Not billed.
passkey.createdA user made a new passkey.
passkey.usedA user signed in with a passkey.
otp.sentA code was emailed.
otp.verifiedA code was checked and was right.
otp.failedFive wrong tries, or the code expired.
email.bouncedAn address bounced and is now suppressed for your app.
email.complainedA user marked a code email as spam.

Limits

WhatLimit
API requests100 a second per app, bursts to 200.
Push pacingUp to 5,000 pushes a second per app.
Push fair useAn average of 10 pushes per device per day over a month.
User ids per push1,000. Use a topic for more.
Devices per user20 for push, 5 trusted for Approve, 10 passkeys.
Message size4 KB, the limit Google and Apple set.
send_atUp to 30 days ahead.
Approval expiry2 minutes.
Code expiry and tries10 minutes, 5 tries, 30 seconds between resends.
Codes per address5 an hour. Per IP address, 20 an hour. Both can be changed per app.
Logs7 days, 30 on Pro. Approval records 90 days, 1 year on Pro.

Android delivery in India

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

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

Something missing? Email hello@signops.dev and the answer goes into these docs.