One interface for software and AI to reach humans.
M91 rings a phone when software needs a person
Software can automate a lot. But sometimes a person has to decide, approve or step in. Then your software calls M91, and M91 rings a phone until somebody answers.
Rings through silent modeEven in Do Not Disturb.
Keeps goingUntil someone answers. Then it phones.
Tells your softwareWho answered, and what they chose.
Email waits. SMS buzzes once. M91 does not stop.
Your system already knows what it needs. The missing piece is the human. Other tools hope someone sees the message. M91 makes sure.
EmailWaits to be read
SMSBuzzes once, then stops
ChatScrolls away
M91Rings until a person answers
It phones if nobody answers (HIGH and CRITICAL).
One person can take it for everyone, and the other phones stop.
A problem that repeats becomes one alert, not a flood.
It records who answered and when.
You paste one URL. That is the whole integration.
No SDK. No API key for the simple case. Your team only needs the M91 app.
Alert a team
Your softwareNeeds a person
M91Rings the phones
A personTakes it on
Ask for a decision
SoftwareAsks
M91Rings
A personAnswers
AnswerComes back
SoftwareContinues
An alert is a short message with buttons
Made to be read on a lock screen. A person taps one button and they are done.
Skip it when it can wait until morning, when your code can decide on its own, or when you want a chat. See each one with an example in Where to use M91.
You answer. Your software asks. M91 connects them.
YouInstall the app, add people, and answer alerts.
Your softwareDecides when to ask, then reads the answer and closes the alert.
M91Rings the phones, escalates, and returns the answer.
A channel is a group of people who get the same kind of alert. Name it after what it is for, such as Production, Payments or Security. Tap Channels, then New channel.
Private: only people you invite can join. Choose this for almost every team.
Public: anyone with the link can join.
New channel✕
Payments
PrivateOnly people you invite can join
✓
PublicAnyone with the link can join
Create
Channels → New channel.
Step 3Invite members
Open the channel and tap the people icon. Choose a country code, type a phone number and tap +. Add everyone who should be woken.
Each person installs M91, signs in with that number, and accepts the invitation.
Only people who accepted get alerts. An empty channel reaches nobody.
You are already in it, as the owner.
Members 3✕
+91 ⌄Phone number+
Y
You+91 ••••• •3210
OWNER
A
Aman+91 ••••• •1182
ACCEPTED
R
Riya+91 ••••• •7745
INVITED
The Members list.
Step 4Get your send link
Your channel already has a send link. It is one URL that lets your software alert this channel. Tap the copy icon to copy it.
Paste it into your website, a server, an automation or an AI agent.
On its own it only checks that the link works. Alerts need a title.
Treat it like a password. Anyone who has it can alert this channel.
‹Payments
Your channel is ready
Send a test alert
https://m91-api.msg91.com/s/ sk_••••••••••••••
Paste it into your website, a server, an automation or an AI agent. How to send alerts…
Your channel is ready. Copy the link, or send a test alert.
Step 5Try it with a test alert
Tap Send a test alert on the channel screen. Everyone in the channel gets a notification, even on silent. Open it and tap a button to see how it works.
02:14
Thursday, 8 October
TIME SENSITIVEnow
HIGH · Payment 4821 failedCard declined 3 times. Customer is waiting.
A test alert arrives.
You are ready to receive alerts. To add M91 to your own project, go to Setting up.
When an alert arrives: respond, then close it
This is the cycle every person in the channel goes through.
1. It ringsEven on silent
2. Open itRead what happened
3. RespondTake it or not
4. Do the workAdd a note or file
5. Close itWhen it is done
1Your phone rings
You get a time-sensitive notification at full volume, even on silent and Do Not Disturb. If nobody responds, HIGH and CRITICAL alerts escalate to a phone call.
2Open the alert
Tap it. You see how urgent it is, the title, the details, and who has not answered yet. The response buttons are at the bottom.
‹Share
HIGHOPEN
Payment 4821 failed
Card declined 3 times. Customer is waiting.
Payments · Just now · 4 people receiving this
RESPONSES
4 people have not answered ›
VIEW ACTIVITY ⌄
I’ll handle it
Removed from everyone else in the channel
Seen
Remains for everyone else in the channel
Your custom response
The alert. Pick a response at the bottom.
3Respond
Choose one button. There are two kinds:
Take it on (for example I’ll handle it). The alert disappears for everyone else in the channel, and it can’t be undone. Use it only when you are really taking it.
Not mine (for example Seen). You have seen it, but it stays for everyone else.
Or type your own reply in the box at the bottom. It never takes the alert.
You get one response, and it is final. The app asks you to confirm before it sends.
I’ll handle it
You’re taking this on. It removes the alert from everyone else in the channel, and can’t be undone.
CancelRespond
Confirm. Taking it on removes it from everyone else.
4Add a note or a file, if asked
Some buttons ask for a note, a file, or both, so your answer comes with a reason or proof. Fill it in and tap the button again to send.
I’ll handle it✕
NOTE (REQUIRED)
Add a note
ATTACHMENT (REQUIRED)
Add an attachment
I’ll handle it
The button asks for a note and an attachment.
I’ll handle it✕
NOTE (REQUIRED)
I know the issue and will fix it
ATTACHMENT (REQUIRED)
photo.jpg ✕
I’ll handle it
Write the note and add the file.
5Everyone sees what happened
The alert now shows Action taken: who took it, what they chose, and their note and file. Your software can read the same decision, so it knows who owns the problem.
‹Share
ACTION TAKEN
Y
I’ll handle it You FOR EVERYONE
I know the issue and will fix it
photo.jpg
Just now
RESPONSES
Everyone answered ›
Close alert
Action taken, with the note and file.
6Close the alert
When the problem is fixed, tap Close alert. This tells everyone it is done, and it can’t be undone. An alert stays open until somebody closes it, even after someone takes it on. Closing also lets the next failure alert again. Your software can close it for you too.
Close this alert?
It’s done — nobody else needs to act on it. This can’t be undone.
CancelClose
Close this alert? Nobody else needs to act on it.
What if nobody answers?
M91 keeps ringing. HIGH and CRITICAL alerts start phoning people. If nobody ever answers, M91 stops, tells the channel owner, and leaves the alert open, because “we stopped trying” is not the same as “it is handled”.
Copy this prompt and give it to your coding agent (Claude Code, Cursor, anything). It finds where alerts belong in your project and wires them up.
Setup prompt
Set up M91 alerting in this project, so a real person gets woken when something breaks.
Read https://m91-api.msg91.com/llms.txt and follow it. Nothing to install.
Start with quickstart.md, then playbook.md, before writing any code.
My send link — ask me for it. It is a live credential, so store it the way this
project already stores its secrets, never in source.
Faster, if your tools let you install one:
npx skills add MSG91/m91-skill --skill m91-alerting -g
The prompt tells your agent to ask you for the send link, so it never ends up in your code.
1
Copy the prompt
Use the button above, or in the app: open your channel, tap the paper-plane icon, Setup With AI Skill.
2
Paste it into your coding agent
It reads the M91 guide, then asks you for your send link.
3
Your first alert rings this phone
The agent sends a test and tells you what came back.
➤How to send alerts to this channel?
Setup With AI SkillSetup Manually
Setup M91 with your A.I.
Copy the Setup Prompt
One prompt. Any coding agent.
01Copy the PromptOne tap on the button above.
02Give it to Your AgentPaste it into Claude Code, Cursor or any coding agent.
03Your Alerting Is IntegratedIts first alert rings this phone.
Setup With AI Skill
Setup Manually
1
Build the alert in the app
Tap the paper-plane icon, then Setup Manually. Choose the buttons people can tap.
2
Copy the code
Tap Copy cURL and paste it into your project. Send alert tries it on your phone first.
3
Check it works
Run the three checks below.
➤How to send alerts to this channel?
Setup With AI SkillSetup Manually
I’ll handle itSHARED✕
SeenPERSONAL✕
+ Add response option
Allow custom response
attachments
https://your-site/photo.jpg
callbackUrl
https://your-server/m91
Auto close alert
Send alert
Copy cURL
Setup Manually
# Raises nothing. Nobody is alerted.
curl -sS "$M91_SEND_LINK"
# You should see: the name of your channel
curl -sS -X POST "$M91_SEND_LINK" \
-H "Content-Type: application/json" \
-d '{"title":"M91 test","severity":"LOW","isAutoClose":true,
"responseOptions":[{"label":"I am on it","effect":"SHARED"},
{"label":"Seen","effect":"PERSONAL"}]}'
# You should see: "delivered" above 0, and a phone ringing
# Tap a button on the phone first, then:
curl -sS "$M91_SEND_LINK/alerts/<_id from check 2>"
# You should see: "decision" with the button that was tapped
Check 1 must have nothing after the link. A ?title=… sends a real alert. If delivered is 0, nobody in the channel has accepted yet.
If your environment cannot reach m91-api.msg91.com, say so, and hand over the curl to run.
Windows PowerShell?
Use Invoke-RestMethod, not curl.exe, or you get MALFORMED_JSON.
Where to use M91
It fits anywhere a URL fits. A send link is just a URL, so M91 goes in places a library never could. If it can make an HTTP request, it can reach a human.
A home-screen shortcut, so anyone has a panic button that wakes the team
A browser
A bookmark
Devices
A smart button, a home-automation rule, a PLC with an HTTP block
Your own code
A curl, fetch or requests.post in the failure branch
An AI agent
One call at the point it must not decide alone
So the useful question is rarely “where does this go in the code?” It is: what would I want a person to know or decide, right now? In every example below, $M91_SEND_LINK is your channel’s send link from Quickstart.
Monitoring
Your monitor already detects the problem. Let it wake the person who can fix it. In Grafana, Sentry, UptimeRobot or Better Stack, add a webhook notification and paste the send link as the URL.
customId makes a check that fails every minute raise one alert, not sixty.
respond adds the buttons: the first is SHARED, the rest PERSONAL.
When the monitor recovers, close the alert so the next failure can alert again:
curl -X POST "$M91_SEND_LINK/close" -H "Content-Type: application/json" -d '{"customId":"api-health"}'
Automation
Add one step that calls the send link. Use it when a workflow fails, or reaches a step only a person can do.
viaSocket
viaSocket has a ready-made Send Channel Alert action. Paste your send link, write the title, and press Test. The response shows delivered, the number of phones reached.
viaSocket: Send Channel Alert. The send link is masked here. Use your own.
Open Description, Severity, Custom Alert ID, Allow Response Options to add buttons and a customId.
Zapier, Make, n8n, Pipedream
Add one HTTP request step that calls the send link.
Setting
Value
Method
POST
URL
Your send link
Header
Content-Type: application/json
Body
{"title":"Order {{order_id}} needs review","severity":"MEDIUM","customId":"order-{{order_id}}","responseOptions":[{"label":"I will review it","effect":"SHARED"},{"label":"Seen","effect":"PERSONAL"}]}
Put the thing the workflow is about into customId, so a retried run does not ring twice. Add a callbackUrl if a later step must wait for the answer.
CI/CD
Alert a person when a deploy fails or a release needs sign-off. Save the send link as a secret, and call it only on failure.
# GitHub Actions
- name: Alert on failure
if: failure()
run: |
curl -X POST "${{ secrets.M91_SEND_LINK }}" \
-H "Content-Type: application/json" \
-d '{"title":"Deploy to prod failed","severity":"HIGH","customId":"deploy-prod",
"responseOptions":[{"label":"I am on it","effect":"SHARED"},{"label":"Seen","effect":"PERSONAL"}]}'
For an approval gate, send two SHARED options (Approve and Reject) and wait for decision. See AI agents for the same pattern.
A spreadsheet
The sheet stays the source of data. M91 is the path to the person. In Google Sheets, a formula can call the link when a cell crosses a limit.
Sheets recalculates often, so always set a customId. While that alert is open, repeats alert nobody.
A phone
Save the send link as a home-screen shortcut. Somebody non-technical gets a panic button that wakes the whole team, with no app to open and no workflow to build.
On iPhone, create a Shortcut with Get Contents of URL and add it to the Home Screen. On Android, add the link as a home-screen shortcut from your browser.
A browser
Bookmark a link that has a title. Opening it raises the alert. You get a small result page back, not JSON.
Any device that can send an HTTP request can use M91. Set a smart button, a home-automation rule or a PLC’s HTTP block to call the link. The device does not need to understand M91.
GET /s/<token>?title=Freezer+3+above+limit&severity=CRITICAL&customId=freezer-3 HTTP/1.1
Host: m91-api.msg91.com
Backend code and scripts
Alert from the exact place something goes wrong: a webhook handler that catches and logs, a job that did not run, a refund that failed. Read the link from your secret store and post from the failure branch.
curl -X POST "$M91_SEND_LINK" -H "Content-Type: application/json" -d '{
"title": "Disk 94% on db-1", "severity": "HIGH", "customId": "disk-db-1",
"responseOptions": [{"label": "I am on it", "effect": "SHARED"},
{"label": "Seen", "effect": "PERSONAL"}]}'
Every agent framework has a point where the run stops for a person: LangChain’s approval tools, LangGraph’s interrupt(), a Vercel AI SDK tool that needs approval. None of them says how that person is reached, and the default is a web page somebody has to be watching. M91 is the other half: the run stops, a phone rings until somebody answers, and the answer is what the run continues with.
Agent pauses → M91 rings a phone → a person taps Approve or Reject → agent continues or stops
The pattern
Ask. Raise an alert whose answers are SHARED options. Two SHARED options make a question with two answers: whoever answers first decides, and nobody else is asked.
Get the answer. Use a callbackUrl (best), or poll GET <link>/alerts/<customId> until decision is set.
Own the timeout. There is no server-side expiry. Decide what silence means, treat it as the safe outcome (usually “no”), and close the alert yourself.
LangChain
M91ApprovalTool is a tool the agent calls itself. It blocks until somebody answers on their phone.
from m91.integrations.langchain import M91ApprovalTool
approval = M91ApprovalTool(
send_link=os.environ["M91_SEND_LINK"],
default_approve_label="Refund",
default_reject_label="Hold",
default_timeout_seconds=600,
)
agent = create_react_agent(
init_chat_model("claude-sonnet-5"),
[approval, *your_tools],
prompt=(
"Before any refund, payment, deletion or deploy, call "
"m91_request_approval and wait. Only the exact string 'approved' "
"lets you continue."
),
)
LangGraph
LangGraph already knows how to stop a graph and resume it. M91InterruptNotifier tells a person it stopped, and returns their decision.
from langgraph.types import Command
from m91.integrations.langgraph import M91InterruptNotifier
notifier = M91InterruptNotifier(send_link=os.environ["M91_SEND_LINK"], timeout=600)
result = graph.invoke(state, config)
if result.get("__interrupt__"):
data = result["__interrupt__"][0].value
decision = notifier.notify(title=data["question"], custom_id=data["custom_id"])
result = graph.invoke(Command(resume=decision), config)
Vercel AI SDK
createM91ApprovalTool is a tool inside generateText, next to the rest.
import { M91Client } from 'm91';
import { createM91ApprovalTool } from 'm91/integrations/vercel-ai';
const m91 = new M91Client(process.env.M91_SEND_LINK!);
const { text } = await generateText({
model: openai('gpt-4o'),
tools: {
m91_request_approval: await createM91ApprovalTool(m91, {
severity: 'HIGH',
approveLabel: 'Refund',
rejectLabel: 'Hold',
timeoutSeconds: 600,
}),
},
stopWhen: stepCountIs(10),
prompt: 'Refund order ORD-40122 if it checks out.',
});
No SDK? The same gate in four lines
The SDK is a convenience, not a requirement. The send link works today with plain HTTP.
# ask
curl -s "$M91_SEND_LINK" -H 'Content-Type: application/json' -d '{
"title": "Refund 48,000 on ORD-40122?", "severity": "HIGH",
"customId": "refund-ORD-40122",
"responseOptions": [{"label":"Refund","effect":"SHARED"},
{"label":"Hold","effect":"SHARED"}]}'
# then poll until `decision` is set
curl -s "$M91_SEND_LINK/alerts/refund-ORD-40122" | jq .data.decision
Three things to get right
Each of these fails silently, so they matter.
Only “approved” continues. The tool returns approved, rejected or error:…. A rejection, a timeout and an unreachable channel all mean do not proceed. Never write if decision:, because every value is truthy.
customId names the condition, not the moment.refund-ORD-40122, never a timestamp. A timestamp makes a new alert every time and nothing collapses.
Only a SHARED response sets decision. A PERSONAL one is an acknowledgement, so “Seen” can never read as “Approved”.
The SDK packages are not published yet, so there is nothing to install. Use the send link directly, as above. If the agent already knows exactly which person must act, use the Platform API to alert that person by phone number.
Look for places where failures go unnoticed
The useful question is not “add alerting”. It is: where in this project does something fail and nobody currently finds out? Look for these:
In the code
Why it earns an alert
A webhook handler that catches and logs
Payments and signups fail here, and the log is read tomorrow
A scheduled job with a deadline
Nothing reports a job that simply did not run
A queue or backlog past a threshold
The system is degrading while everything still returns 200
A dead-letter queue receiving anything
By definition nothing else will retry it
A payment, refund or payout failing
Money, and a customer already waiting
A health check that failed N times
The difference between a blip and an outage
A deploy or migration needing a human gate
The pipeline is blocked until a person decides
An agent or automation about to do something irreversible
See AI agents above
Auth or security: repeated failures, a new admin, a key used from nowhere
Nobody is watching the log at 2am
A third-party integration returning errors in a row
Your code is fine and your product is broken
Then decide, for each one: should it wake somebody at 3am, or wait until morning?
Send an alert only when a person can act now
The test is not “is this bad?” but “is there something a specific person can do about it right now?” All three must hold:
A person is needed. Not a retry, not a dashboard, not a log line.
Now matters. Waiting until morning makes it worse.
Somebody can act. There is a thing to do, and the alert says what.
If one fails, it is a log, a metric or a daily digest. The common failure is volume, not accuracy. Never wire M91 into a generic error handler or a logger. Alert on named conditions you chose on purpose.
Give each problem a name so repeats collapse
Any check that runs on a schedule needs a customId: the name of the condition, not the run. Closing is what re-arms it, so close the alert when the condition clears.
customId: "payments-webhook-failing" ✓ the condition
customId: "alert-2026-10-01T03:14:22Z" ✗ the run: rings every minute
curl -X POST "$M91_SEND_LINK/close" -H "Content-Type: application/json" -d '{"customId":"payments-webhook-failing"}'
Before you say it works
The bare link returns the right channel.
One real alert was sent and a real phone rang. You read the response.
Every alert has two or more response options, or an explicit [] with isAutoClose: true.
Every recurring condition has a customId, and something closes it.
Severity is deliberate. Nothing is CRITICAL that could wait.
The link is in configuration, not in source, and not logged.
You reported what actually came back, not what the code should do.
API
The HTTP interface for M91. Raise alerts, read the human decision, and manage who gets reached. Base URL https://m91-api.msg91.com.
Introduction
There are two ways to reach a human. Pick by who you are alerting.
Links that start with https://m91.msg91.com/s/ must be changed. That was the API’s address until October 2026; it is now m91-api.msg91.com, and the old host no longer reaches the API. Replace the host; the token after /s/ stays the same.
A team: Send link
One person: Platform API
Use it when
The people are a fixed group in a channel
Your product discovers its users at runtime
Credential
Send link token in the URL (/s/sk_…)
API key, Authorization: Bearer m91sk_…
Comes from
A channel, in the M91 app
The M91 app: Settings → API keys
Alerts
Everyone in that channel
Any phone number you invited and who accepted
Can create channels
No
Yes
One URL is the whole integration: no SDK, no API key, no account at the other end. That is the send link.
A send link or API key can raise, read and close alerts. Neither can record a response. That is always a person, on their own phone.
Every response is { "success": true, "data": { … } }. Read your field from inside data, never from the top level.
Send link
Check a send link
GET/s/{token}
Confirms the link is live and names the channel it points to. It raises nothing and wakes nobody.
Authorization Send link token in the path. No headers needed.
Path parameters
token
stringrequired
The sk_… part of your send link. Use the whole link as one string.
Responses
200 The link works.
401 Wrong or revoked token.
A bare GET never raises an alert. Link previews, crawlers and mail scanners fetch URLs on their own, so only a request with a title rings phones. Remove any ?title= before testing.
Request
curl "$M91_SEND_LINK"
const res = await fetch(process.env.M91_SEND_LINK);
const result = await res.json();
import os, requests
res = requests.get(os.environ["M91_SEND_LINK"])
print(res.json())
Raises an alert from a plain URL. Use it wherever you can only paste a link: a webhook field, a bookmark, a shortcut, a spreadsheet.
Authorization Send link token in the path. No headers needed.
Path parameters
token
stringrequired
The sk_… part of your send link. Use the whole link as one string.
Query parameters
title
stringrequired
5–2000 characters. Without it nothing is raised.
description
string
Up to 2000 characters.
severity
enum
LOW, MEDIUM, HIGH or CRITICAL. Default MEDIUM. See Severity.
respond
string
Comma-separated answer labels, up to 6. The first is SHARED, the rest PERSONAL. A trailing ! forces SHARED: respond=On it,Seen!.
isAutoClose
boolean
truefalse10yesnoonoff. Default false.
customId
string
Up to 200 characters.
Responses
201 A new alert was raised. Same body as POST.
200 A repeat was suppressed.
400 A field is invalid.
401 Wrong or revoked token.
409 Nobody can receive. See CHANNEL_EMPTY.
429 More than 60 sends a minute.
The link form carries only these fields. For attachments, callbackUrl, other or required / optional, use POST.
A browser gets an HTML result page. Anything else gets JSON.
Request
curl -G "$M91_SEND_LINK" \
--data-urlencode "title=Payment 4821 failed" \
--data-urlencode "severity=HIGH" \
--data-urlencode "respond=I am on it,Seen" \
--data-urlencode "customId=payment-4821"
const url = new URL(process.env.M91_SEND_LINK);
url.searchParams.set("title", "Payment 4821 failed");
url.searchParams.set("severity", "HIGH");
url.searchParams.set("respond", "I am on it,Seen");
url.searchParams.set("customId", "payment-4821");
const res = await fetch(url);
const result = await res.json();
import os, requests
res = requests.get(
os.environ["M91_SEND_LINK"],
params={
"title": "Payment 4821 failed",
"severity": "HIGH",
"respond": "I am on it,Seen",
"customId": "payment-4821",
},
)
print(res.json())
{
"success": true,
"data": {
"_id": "6703f1a2c8e4b90012ab34cd",
"customId": "payment-4821",
"status": "OPEN",
"delivered": 0,
"deduplicated": true,
"message": "An alert with customId \"payment-4821\" is already open, so nobody was alerted again."
}
}
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "severity must be one of: LOW, MEDIUM, HIGH, CRITICAL",
"details": {
"fields": [
{
"field": "severity",
"message": "severity must be one of: LOW, MEDIUM, HIGH, CRITICAL"
}
]
}
}
}
{
"success": false,
"error": {
"code": "CHANNEL_EMPTY",
"message": "Nobody has joined this channel yet",
"details": {
"unregistered": [
"919876543210"
]
}
}
}
Read an alert
GET/s/{token}/alerts/{id}
Who was reached, who answered, and what they chose. This is how your software learns the human decision.
Authorization Send link token in the path. No headers needed.
Path parameters
token
stringrequired
The sk_… part of your send link. Use the whole link as one string.
id
stringrequired
The customId you sent, or the _id the send returned.
Responses
200 The alert. decision is null until someone gives a SHARED response.
401 Wrong or revoked token.
404 No alert with that id on this channel.
Only a SHARED response sets decision. A PERSONAL one such as “Seen” never does, so an agent cannot read it as “Approved”. If responses has entries and decision is null, those options were PERSONAL.
responses[] holds every answer with label, effect, optionIndex, at, by (the responder’s id), and note and attachments (always present: null and [] when not given). pending is how many people have not answered yet.
Poll every few seconds, or use a callback. M91 never expires an alert, so set your own timeout.
Request
curl "$M91_SEND_LINK/alerts/deploy-prod"
const res = await fetch(process.env.M91_SEND_LINK + "/alerts/deploy-prod");
const result = await res.json();
import os, requests
res = requests.get(os.environ["M91_SEND_LINK"] + "/alerts/deploy-prod")
print(res.json())
Digits only, 8–15, country code included, no +. Example 919876543210.
title
stringrequired
5–2000 characters. The line someone reads on a lock screen. summary is accepted as an alias.
description
string
Up to 2000 characters. What happened and what is needed to act.
severity
enum
LOW, MEDIUM, HIGH or CRITICAL. Default MEDIUM. See Severity.
responseOptions
array
Up to 20 answer buttons. See Response options. Omitted means nobody can answer and nothing can close the alert.
other
boolean
Default true. Adds a Something else reply where the person types an answer.
attachments
string[]
https links to files shown with the alert. They must open without a login. See Attachments.
callbackUrl
string
An https URL M91 POSTs to when someone answers and when the alert ends. See Callbacks.
isAutoClose
boolean
Default false. Whether the alert closes itself. See How an alert ends.
customId
string
Up to 200 characters. Your own id for the condition. Collapses repeats. See Repeats.
Response option object
label
stringrequired
Up to 24 characters. Written as the answer, in the first person. Labels must differ.
effect
enum
SHARED (I take responsibility) or PERSONAL (I do not). Default SHARED.
required
string[]
What the person must add to answer: "note", "attachment", or both.
optional
string[]
What the person may add: "note", "attachment", or both. Not the same field as in required.
Responses
201 A new alert was raised. Same body as the send link.
200 A repeat was suppressed.
404not_a_recipient. That phone was never invited. An alert never invites anyone.
400 A field is invalid.
401 Bad key.
An invited person is not an M91 user yet, so an alert to them would arrive as an ordinary message that does not break through Do Not Disturb. Add the recipient first.
An alert is a short message with buttons. Only the title is required. Add the other parts when the situation needs them.
Part
Add it when
Field
The headline
always
title
The details
the person has to decide something and the title is not enough
description
How urgent
always. The default never phones anyone
severity
The buttons
the person has to do something, which is almost every alert
responseOptions
Proof
a graph, screenshot or document would make it obvious
attachments
A way back
your software is waiting for the answer
callbackUrl
Two more settings decide how it ends (isAutoClose) and what to call the problem (customId). Both are explained below.
Write the title like a text to a friend: say what broke and where. Payments API returning errors is good. Alert is not. Put numbers and next steps in description, and link long logs with attachments.
Severity
Every alert rings loudly, even on silent. Severity only decides what happens if the first ring is ignored.
Level
Think of it as
What M91 does
LOW
FYI
Rings once. Does not chase.
MEDIUM (default)
Look at this soon
Rings again for 2 minutes. Never phones anyone.
HIGH
Needs a person now
Keeps ringing for 18 minutes. Phones only if nobody has responded and nobody has even opened it.
CRITICAL
Money or safety is at risk
Keeps ringing for 60 minutes and phones people, two at a time. Tells the channel owner after 15 minutes.
As soon as one person responds, all of this stops.
If nobody ever answers, M91 stops trying, tells the channel owner, and leaves the alert open.
Phone calls start 45 seconds after nobody has responded, with up to 10 people per round and 50 calls per alert. Each person gets at most one call every 5 minutes.
Do not mark everything CRITICAL. If people get phoned for small things, they stop picking up, and the next real emergency is missed.
Response options
These are the buttons the person taps. They pick one, once, and it cannot be changed. There are two kinds:
Type
It means
What happens to everyone else
SHARED
“I will take this.”
The alert disappears for them.
PERSONAL
“I have seen it, but it is not mine.”
It stays for them.
Write each button as the answer, in the first person, up to 24 characters: I am on it, Approve, Already fixed. Not OK or Option 1.
If you want
Send these buttons
One person to take it on
one SHARED button, plus PERSONAL ones like Seen
A yes or no decision
two SHARED buttons: Approve and Reject
A reason when the answer is no
make the no button ask for a note: "required": ["note"]
Everyone to confirm
only PERSONAL buttons. The alert closes when the last person answers
Just a heads-up, no buttons
"responseOptions": [] and "isAutoClose": true
Two common mistakes. One button on its own is not a choice: it only tells you somebody saw it. And leaving responseOptions out is not the same as sending []. If you leave it out, nobody can answer and nothing can close the alert, so it sits open forever.
Asking for a note or a file. A button can require or allow a note, a file, or both: "required": ["note"], "optional": ["attachment"]. Use it when the answer needs proof or a reason. Do not put a requirement on every button, because people need a quick one-tap way out.
Free text. By default M91 adds a Something else option where people type their own reply. It never takes the alert, so it never clashes with your buttons. Leave it on unless the answer must be one of your buttons ("other": false).
When does an alert close?
An alert stays open until somebody closes it. isAutoClose is false unless you say otherwise.
What happens
Does it close?
Someone taps a SHARED button
only if isAutoClose is true
Everyone tapped a PERSONAL button, and there was no SHARED button
yes
No buttons ([]) and isAutoClose: true
yes, as soon as it is delivered
No buttons and no isAutoClose
never. Nothing can close it
There is no timer. If your software is waiting for a person, decide what silence means, treat it as “no”, and close the alert yourself.
Repeats and customId
customId is a name you give to a problem, like payment-4821 or disk-full-db1. Use the same name every time the same problem happens.
While that alert is open, sending it again does nothing. Nobody is disturbed twice, and the new text is ignored.
Closing the alert frees the name. If the problem comes back, the same alert reopens (earlier answers are cleared).
Do not put a timestamp in it. Every send would look like a new problem and ring again.
The name is per channel, so two channels can both use disk-full.
Separately, identical messages sent within 5 seconds are merged into one.
Both cases return 200 with deduplicated: true and a short message. delivered is 0 or missing, because nobody was alerted again.
Combinations to avoid
If you send
What happens
No buttons and no isAutoClose
Nobody can answer and nothing can close it
Every button asks for something, and other is false
Nobody can answer at all
MEDIUM and nobody looks
Rings for 2 minutes, then goes quiet. No call, ever
HIGH and somebody opens it
No call is made, because somebody is already looking
A SHARED button and isAutoClose: false
One person takes it, the rest are released, and it stays open until closed
callbackUrl with PERSONAL buttons on 300 people
300 callbacks. Use the closed event if you only want the final result
Attachments
Add links to files, such as a graph, a screenshot or a PDF. They show inside the alert. Images appear in the alert, and other files open in the phone’s viewer.
Links only. You cannot upload a file. M91 shows your link and does not keep a copy, so the link has to stay alive.
The link must open without a login. A link to your internal dashboard works on your laptop but not on somebody else’s phone. A signed link works well.
If a link fails, it is skipped and the alert still goes out.
Callbacks
Add a callbackUrl when your software is waiting for the answer. M91 sends it a message when someone answers (answered, once per person) and when the alert ends (closed). Without it, you have to keep asking with GET.
The closed event gives the final picture: reason, all responses, and who is still pending.
The same message can arrive twice. Use idempotencyKey to ignore repeats.
Check the signature so you know it came from M91. It is an HMAC-SHA256 of "<timestamp>.<raw body>", using the secret returned when the alert was created. Use the raw body and reject old timestamps.
Reply with 2xx quickly. note and attachments are always there (null or [] when empty).
Delivery
A 2xx means accepted, not delivered
True
a 201 says M91 recorded the alert and queued it, usually within a few hundred milliseconds. Fan-out to devices happens afterwards, in a separate process. delivered counts recipients the alert was dispatched to.
Why
a push service will accept a token the device platform has since invalidated and report success, so a dead registration fails silently at the far end, after everything on this side reported fine.
Do
assert on delivered, not on the status code, and treat delivered: 0 as a failure even though the request succeeded.
Don’t
build a workflow that assumes an alert was seen. The only evidence of that is a response coming back, which is what responseOptions are for.
Delivery depends on things outside M91
True
a phone with notifications denied, no network, or the app uninstalled still counts as a recipient and makes no sound. unregistered is invited but never signed in. noDevice is signed in with no device, usually after a reinstall.
Why
neither retries nor a durable queue can fix a device that is set not to listen.
Do
have each member tap Send a test alert on their own phone when they join, and open the app once after a reinstall.
Don’t
make M91 the only record of something that must not be missed.
Errors
Every error is { "success": false, "error": { "code", "message", "details", "docs" } }. Branch on code. error.docs links to the fix.
Status
code
Meaning
What to do
400
VALIDATION_ERROR
A field is invalid. details.fields names it. Unknown fields are rejected.
Fix that field.
400
MALFORMED_JSON
Body is not JSON.
Fix quoting. Send Content-Type: application/json.
401
UNAUTHORIZED
Token or key is missing, wrong, forged or revoked.
Copy it again. Do not retry.
403
FORBIDDEN
Not allowed. On close, usually already resolved.
On close, treat as success.
404
NOT_FOUND
Absent, or not yours. The two look the same on purpose.
Check the id and channel.
409
CHANNEL_EMPTY
Nobody can receive. details.unregistered lists invited numbers with no account.
People must install, sign in and accept.
413
PAYLOAD_TOO_LARGE
Body over 1 MB.
Send a summary and a reference.
429
RATE_LIMITED
Over 60 sends a minute per link. Retry-After is always set.
Wait, then add a customId.
500
INTERNAL_ERROR
Unexpected.
Retry with backoff.
Limits
Item
Limit / default
title
5–2000 characters
description
2000 characters
customId
200 characters
Response option label
24 characters
Response options
20 in JSON, 6 in a link
Channel name
80 characters
Key or sender label
60 characters
phone
8–15 digits, no +
Members per invite call
500
Request body
1 MB
Send rate
60 per minute per send link
Duplicate payload window
5 seconds
Default severity
MEDIUM
Dialling severities
HIGH, CRITICAL
Escalation delay
45 seconds
People per wave
10
Calls per alert
50
Calls per person
1 per 5 minutes
Retention
closed alerts are deleted after 30 days by default
Troubleshooting
What you see, why it happens, how to fix it. Branch on error.code, not the message text. error.docs links to the matching entry.
Connection and link
The call never completes
You seerefused, DNS failure or a hang
Why it happens
There is no network route to m91-api.msg91.com, common in CI, containers and sandboxes.
Fix
Check curl -sS -o /dev/null -w '%{http_code}' https://m91-api.msg91.com/api/public/index.md prints 200. If you are an agent, say you could not verify and hand over the exact command.
401 on every request
You see"UNAUTHORIZED" "Invalid sender token"
Why it happens
The token is wrong, revoked, or the URL was assembled from parts (a trailing slash or a truncated copy).
Fix
Copy the link again from the app and use it as one string. A revoked link cannot be recovered.
A bare GET says ok but nothing rang
You see"ok":true
Why it happens
Not a failure. A link without title is a connection test, so previews and crawlers cannot wake a team.
Fix
Add ?title=… to raise an alert.
Rejected requests
400 VALIDATION_ERROR
You see"title is required" "Unknown field: " "must be one of: LOW, MEDIUM, HIGH, CRITICAL"
Why it happens
A field is wrong: missing or short title (5–2000), a typo or unknown field, lowercase severity, an effect other than SHARED/PERSONAL, a label over 24 characters, or too many options.
Fix
Read error.details.fields, which names the field. There is no recipient field.
400 MALFORMED_JSON, or an empty body
You see"MALFORMED_JSON"
Why it happens
Shell quoting, a missing Content-Type: application/json header, or PowerShell mangling curl.exe quoting.
Fix
Use -d @payload.json with the header. On PowerShell use Invoke-RestMethod (see Setting up).
413 PAYLOAD_TOO_LARGE
You see"PAYLOAD_TOO_LARGE"
Why it happens
The body is over 1 MB, usually a log in description.
Fix
Send a summary and a link instead.
Nobody was reached
409 CHANNEL_EMPTY
You see"CHANNEL_EMPTY" "Nobody has joined this channel yet"
Why it happens
Nobody who can receive alerts is in the channel. Invites are not accepted, or M91 is not installed. details.unregistered lists them.
Fix
People must install M91, sign in and accept. Check this first.
delivered is 0 or low
You see"delivered":0
Why it happens
Either a deduplicated send ("deduplicated":true is also present), or the roster: unregistered (never signed in), noDevice (signed in, no device, often after a reinstall), or invites not accepted.
Fix
Check deduplicated first, then ask people to open the M91 app once. Always assert on delivered, not the status code.
The phone stayed silent
You seea normal 201
Why it happens
delivered means sent, not heard. Notifications may be denied for M91, or the device token is stale.
Fix
Check notification permission and reopen the app. Only a response proves somebody saw it.
No phone call was made
Why it happens
The alert was LOW or MEDIUM, or HIGH and somebody opened it, or somebody responded, or that person was called recently, or the hourly call quota is spent.
Fix
Use CRITICAL when a call is always warranted.
Repeats and closing
deduplicated: true
You see"deduplicated":true
Why it happens
Working as intended. An alert with that customId is still open, or an identical message arrived within 5 seconds.
Fix
Close the first alert to raise a fresh one. For a different problem, use a different customId. In manual tests, change any field.
Alerts never repeat, or every repeat is new
You see"deduplicated":true forever, or a flood then 429
Why it happens
An alert was never closed, so repeats are absorbed. Or the customId has a timestamp or UUID in it, so nothing matches.
Fix
Use one stable name per problem (disk-full-node7) and close the alert when the problem clears. A worse problem needs its own customId.
429 RATE_LIMITED
You see"RATE_LIMITED"
Why it happens
More than 60 sends a minute on one link, usually no customId or a retry loop.
Fix
Honour Retry-After, add a customId, and close when clear.
404 or 403 on close
You see"alert_not_found" "FORBIDDEN"
Why it happens
Normal. Nothing was open with that id, or it was already resolved.
Fix
Treat both as success.
decision stays null
You see"decision":null
Why it happens
Nobody gave a SHARED answer. PERSONAL ones never become a decision, so “Seen” is never read as “Approved”.
Fix
Include a SHARED button and set your own timeout.
API key
401 on API-key calls, or state is null
You see"UNAUTHORIZED"
Why it happens
The key is missing, revoked, or sent without Bearer . A null state means you read it from the top level instead of data.
Fix
Send Authorization: Bearer m91sk_… and read body.data.state. GET /api/v1/me proves a key works.
404 not_a_recipient, or reachable: false
You see"not_a_recipient" "reachable": false
Why it happens
The number was never invited, or has not accepted. An alert never invites anyone, because consent comes first.
Fix
Invite with POST /recipients, then wait for them to accept in the app. Nothing in code can skip this.
A channel created by API key cannot be alerted by it
You seea 4xx when alerting that channel
Why it happens
Keys create channels and alert individuals. Alerting a channel is the send link’s job.
Fix
Use the sendLink returned by POST /api/v1/channels.
Looks like a bug, but is not
A SHARED answer did not close the alert
You seeit stays OPEN
Why it happens
By design.isAutoClose defaults to false. Closing says the problem is over, and usually only a person knows that.
Fix
Close it yourself, or send isAutoClose: true. With no buttons and no isAutoClose, nothing can ever close it.
Somebody wants to change their answer
You seethere is no endpoint
Why it happens
By design. One answer per person, and it is final.
Fix
Raise a new alert, or have them comment in the app.
Limitations & Recommendations
What M91 does not guarantee, and what to do about it. Each point has four short lines: True, Why, Do, Don’t. Delivery limits are in the API.
Reaching people
Only HIGH and CRITICAL make phone calls
True
LOW and MEDIUM ring loudly and stop there. HIGH and CRITICAL call after 45 seconds of no response, in small rounds, and the call only says to open the app.
Why
the default is MEDIUM so a careless alert can never phone anyone.
Do
pick severity on purpose, and put everything people need in title and description.
Don’t
plan on “everyone will get a call”.
A send link alerts a channel, not a person
True
there is no recipient field. The channel is the list, and people only get alerts after they install M91 and accept. An empty channel returns 409.
Why
it keeps phone numbers out of your code, and nobody is added without agreeing.
Do
use narrow channels, and test with Send a test alert from each member’s phone. For one person, use the Platform API.
Don’t
assume a new channel works, or send unrelated alerts through one channel.
The send link
The link is a secret in a URL
True
anyone who has it can alert that channel. Revoking deletes it right away, and there is no rotation.
Why
no headers means any tool with a “URL to call” field can use it, and that is also why it is easy to leak.
Do
keep it in a secret store, call it from your server, never log the request path, and use one link per integration.
Don’t
commit it, put it in frontend code, or share one link everywhere.
A link can read a decision but never make one
True
it can raise, close and read alerts. Neither a link nor an API key can answer. Only a person can.
Why
links leak, and a leaked link must not be able to approve for someone else.
Do
if code can decide, decide in code and skip the alert.
Don’t
build a flow where a machine answers for a person.
Alerts over time
Alerts stay open, and repeats are absorbed
True
an alert stays open until somebody closes it. While it is open, repeats with the same customId do nothing. There is no expiry. Closed alerts are deleted after 30 days.
Why
only your system knows what “too long” means.
Do
close the alert when the problem clears, set your own timeout, and treat silence as “no”. Send pure notices with [] and isAutoClose: true.
Don’t
use a timestamp as customId, or open an alert you never close.
Answers are final, and limits apply
True
one answer per person, and it cannot be changed. You can send 60 alerts a minute per link. Identical sends within 5 seconds are merged.
Why
a SHARED answer means someone owns it, and that must stay clear.
Do
offer two or three clear buttons, add a customId, and back off on 429.
Don’t
retry in a tight loop, or use buttons to collect long answers.
API keys, SDKs and agents
An API key is powerful, so guard it
True
it is shown once and has no scopes. It can create and staff channels but cannot alert them, and a person must accept before a direct alert reaches them.
Why
a leaked key must not be able to ring a team.
Do
keep it on the server, rotate by creating a new key and revoking the old one, and invite first and alert only after accepted.
Don’t
put it in client code, or expect an alert to invite anyone.
An SDK or agent follows the same rules
True
an SDK only wraps the send link. An agent waiting on a person may never get an answer.
Why
nothing a wrapper does changes what the link can do.
Do
continue only on the exact approving answer, treat rejection, timeout and errors as “do not proceed”, and close the alert.
Don’t
let an agent do something irreversible just because an alert went out.
The people on the other end
Too many alerts break alerting
True
every severity rings through silent mode, and that permission disappears when people mute the app or leave the channel.
Why
systems rarely fail by not delivering. They fail by delivering so much that people stop reacting.
Do
keep alerts rare, name each problem with a customId, and review what a channel sends each week.
Don’t
escalate severity out of worry, or send an alert whose answer is “look at it tomorrow”.