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.
- Get an API key. Sign up and copy the secret key from Settings. Keep it on your server only.
- Connect push. In Settings, upload your Firebase service account JSON, and your APNs
.p8key if you send to iOS directly. - Register a device. Your app gets a token from the Firebase or Apple SDK and sends it to your server, which adds it under the user's id.
- Send. Call
/v1/pushwith that user id.
// Your server adds the token from FCM or APNs.
await signops.devices.add({
user_id: "usr_8f21c4", // your own user id
token: fcmToken,
platform: "android", // android | ios | web
});
await signops.topics.subscribe("offers", {
user_id: "usr_8f21c4",
});curl https://api.signops.dev/v1/devices \
-H "Authorization: Bearer $SIGNOPS_KEY" \
-d user_id=usr_8f21c4 \
-d token=$FCM_TOKEN \
-d platform=androidimport { SignOps } from "@signops/node";
const signops = new SignOps(process.env.SIGNOPS_KEY);
// Every device of this user: Android, iOS, web.
await signops.push.send({
user_id: "usr_8f21c4",
title: "Your order is out for delivery",
body: "Ravi is 10 minutes away with 6 items.",
link: "kirana://orders/88213",
});use SignOps\SignOps;
$signops = new SignOps(getenv('SIGNOPS_KEY'));
$signops->push->send([
'user_id' => 'usr_8f21c4',
'title' => 'Your order is out for delivery',
'body' => 'Ravi is 10 minutes away with 6 items.',
'link' => 'kirana://orders/88213',
]);from signops import SignOps
signops = SignOps(os.environ["SIGNOPS_KEY"])
signops.push.send(
user_id="usr_8f21c4",
title="Your order is out for delivery",
body="Ravi is 10 minutes away with 6 items.",
link="kirana://orders/88213",
)curl https://api.signops.dev/v1/push \
-H "Authorization: Bearer $SIGNOPS_KEY" \
-H "Content-Type: application/json" \
-d '{
"user_id": "usr_8f21c4",
"title": "Your order is out for delivery",
"body": "Ravi is 10 minutes away with 6 items."
}'API keys
Every request carries a secret key in the Authorization header. Each app has a live key, sk_live_..., and a test key, sk_test_.... Test keys send to devices and addresses you mark as test in the dashboard, and nothing they do is billed.
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." } }| 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. |
| 403 | not_allowed | The key is a test key used on live data, or the plan does not include 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. |
| 429 | rate_limited | Too many requests. Wait the seconds in Retry-After. |
| 500 | server_error | Our fault. Retrying with the same Idempotency-Key is safe. |
Libraries and SDKs
Node and TypeScript @signops/node, PHP signops/signops-php, Python signops. Anything else calls the REST API.
Android com.signops:android, iOS SignOps on Swift Package Manager, React Native @signops/react-native, Flutter signops.
@signops/web for passkeys and web push registration, or one script tag.
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.
/v1/devicesAdd a device for a user/v1/users/:user_id/devicesList a user's devices/v1/devices/:idRemove a device/v1/devices/importImport tokens from CSV or JSONTokens 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.
/v1/pushSend now or at send_at/v1/push/:idOne message and its results/v1/push/:idCancel a scheduled message/v1/pushList messages, newest firsttitleThe 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.// A topic, an image and a send time.
await signops.push.send({
topic: "offers",
title: "Weekend offer: 20% off fruit",
body: "Mangoes, bananas and apples until Sunday night.",
image: "https://cdn.kiranaexpress.in/offers/fruit.jpg",
send_at: "2026-10-24T18:00:00+05:30",
});curl https://api.signops.dev/v1/push \
-H "Authorization: Bearer $SIGNOPS_KEY" \
-d topic=offers \
-d title="Weekend offer: 20% off fruit" \
-d send_at=2026-10-24T18:00:00+05:30Topics
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.
/v1/topics/:name/subscribeAdd a user to a topic/v1/topics/:name/unsubscribeTake a user out of a topic/v1/topicsList topics and their sizesApprove
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.
/v1/approveCreate an approval request/v1/approve/:idRead a request's status/v1/approve/:id/cancelCancel a pending request/v1/approve/devices/enrollSDK: enrol a device's public key/v1/approve/pendingSDK: requests waiting for this device/v1/approve/:id/respondSDK: signed approve or denyA request is pending until the user answers or 2 minutes pass, then approved, denied or expired. Wait on it with approve.wait, which holds the connection up to 120 seconds, or take the approve.* webhook.
// Someone is signing in on the web.
// Ask the user's trusted phone.
const challenge = await signops.approve.create({
user_id: "usr_8f21c4",
context: "Sign in from Chrome on Windows",
});
// Show challenge.match ("47") on the login page.
const result = await signops.approve.wait(challenge.id, {
timeout: 120,
});
if (result.status === "approved") startSession(result.user_id);// Once, after your own login.
// Keys are made in the Keystore.
SignOps.approve.enroll(userId = "usr_8f21c4")
// The push opens the approve screen.
SignOps.approve.showPending(activity)import { Approve } from "@signops/react-native";
await Approve.enroll({ userId: "usr_8f21c4" });
// A ready-made screen in your colours,
// or your own with Approve.pending().
<Approve.Screen brandColor="#12a463" />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.
/v1/passkeys/registration-tokenToken for the browser to make a passkey/v1/passkeys/verifyConfirm a sign-in result on your server/v1/users/:user_id/passkeysList a user's passkeys/v1/passkeys/:idRemove a passkeyimport { 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.// 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,
);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.
/v1/otp/sendMake and email a code/v1/otp/verifyCheck a code/v1/otp/:idStatus of a code: sent, verified, expired// 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,
});$otp = $signops->otp->send([
'email' => 'ananya@example.in',
'app_name' => 'Kirana Express',
]);
$result = $signops->otp->verify(
$otp['otp_id'],
$request->input('code'),
);
$ok = $result['valid'];otp = signops.otp.send(
email="ananya@example.in",
app_name="Kirana Express",
)
result = signops.otp.verify(
otp_id=otp.otp_id,
code=request.form["code"],
)
valid = result.validcurl https://api.signops.dev/v1/otp/send \
-H "Authorization: Bearer $SIGNOPS_KEY" \
-d email=ananya@example.in -d app_name="Kirana Express"
curl https://api.signops.dev/v1/otp/verify \
-H "Authorization: Bearer $SIGNOPS_KEY" \
-d otp_id=otp_3kd92 -d code=482913On 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.
// Every event is signed. Check it, then act on it.
const raw = express.raw({ type: "application/json" });
app.post("/signops", raw, (req, res) => {
const event = signops.webhooks.verify(
req.body,
req.headers["signops-signature"],
);
if (event.type === "device.removed")
db.tokens.delete(event.data.device_id);
if (event.type === "approve.denied")
alertSecurityTeam(event.data.user_id);
res.sendStatus(200);
});{
"id": "evt_9d2kq81",
"type": "approve.approved",
"created": "2026-10-24T09:41:04+05:30",
"data": {
"challenge_id": "apr_51mz0c",
"user_id": "usr_8f21c4",
"device": "Pixel 8",
"matched": true,
"biometric": "face"
}
}Event types
| Event | When |
|---|---|
| push.sent | A message was handed to FCM, APNs or the browser's push service. |
| push.failed | A device rejected a message. Carries the reason. |
| push.opened | The user opened the push. Needs the SDK in the app. |
| 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. |
| otp.sent | A code was emailed. |
| otp.verified | A code was checked and was right. |
| otp.failed | Five wrong tries, or the code expired. |
| email.bounced | An address bounced and is now suppressed for your app. |
| email.complained | A user marked a code email as spam. |
Limits
| What | Limit |
|---|---|
| API requests | 100 a second per app, bursts to 200. |
| Push pacing | Up to 5,000 pushes a second per app. |
| Push fair use | An average of 10 pushes per device per day over a month. |
| 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. |
| Codes per address | 5 an hour. Per IP address, 20 an hour. Both can be changed per app. |
| Logs | 7 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.