M91
Download on the App Store Get it on Google Play
Start here
OverviewQuickstart
Working with M91
Setting upWhere to use M91
Reference
APITroubleshootingLimitations & Recommendations

M91

One interface for software and AI to reach humans.

POST /s/sk_•••• title: "Payment failed" severity: "HIGH" buttons: [ Approve ] Your software alert M91 rings HIGH Payment failed Approve A phone rings

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.

‹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
What a person sees when they open an alert.
  • How urgent: LOW, MEDIUM, HIGH or CRITICAL.
  • What happened: a headline and a few details.
  • Buttons: the answers people can tap.
  • Who answered: and who has not yet.

Every field is in What goes in an alert.

Connect with a send link or an API key

Pick by who you are alerting.

Send link

For a team. One URL per channel.

Looks like
https://m91-api.msg91.com/s/sk_…
Comes from
A channel, in the M91 app
Alerts
Everyone in that one channel
Use it when
The people are a fixed team
Can create channels
No

API key

For one person, or to create channels from code.

Looks like
m91sk_…
Comes from
The M91 app: Settings → API keys
Alerts
Any phone number you invited
Use it when
Your product alerts its own users
Can create channels
Yes

Use it when a person must act now

An AI agent needs approval
A payment fails
Production goes down
A deploy is blocked
A job does not finish
A device crosses a limit

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.

Start with the Quickstart

QuickstartInstall the app, create a channel, get your send link.Setting upAdd M91 to your project, with an AI agent or by hand.Where to use M91Monitoring, automation, CI/CD, devices and AI agents.APIEvery endpoint and field.

Building with an AI agent? Give it llms.txt. It is the whole map in one file.

Quickstart

Get ready to receive alerts in about two minutes. Install the app, create a channel, invite your team, and copy your send link.

1. InstallThe M91 app
2. CreateA channel
3. InviteYour team
4. GetYour send link
5. Try itA test alert

Five steps, about two minutes

Step 1Install the app

Download M91 on the phone that should receive alerts. Open it and sign in with your phone number. Allow notifications, so alerts can reach you.

Download for iPhoneApp Store→Get it for AndroidGoogle Play→

Step 2Create a channel

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”.

Setting up

Two ways to add M91 alerting to your project: Setup with AI Skill (recommended) or Setup Manually. You need your send link first. Get it in the Quickstart.

Setup with AI Skill

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.

Next: Where to use M91 · What goes in an alert · API

No internet access?

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.

WhereHow it connects
Monitoring: Grafana, Sentry, UptimeRobot, Better StackPaste the link into the webhook field
Automation: Zapier, Make, n8n, viaSocket, PipedreamOne HTTP step
CI/CDA curl guarded by if: failure()
A spreadsheetA formula that fires when a cell crosses a limit
A phoneA home-screen shortcut, so anyone has a panic button that wakes the team
A browserA bookmark
DevicesA smart button, a home-automation rule, a PLC with an HTTP block
Your own codeA curl, fetch or requests.post in the failure branch
An AI agentOne 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.

https://m91-api.msg91.com/s/<token>?title=API+health+check+failing&severity=HIGH&customId=api-health&respond=I+am+on+it,Seen
  • 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.

The viaSocket Send Channel Alert action with a successful response
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.

SettingValue
MethodPOST
URLYour send link
HeaderContent-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.

=IF(B2<10, IMPORTDATA("https://m91-api.msg91.com/s/<token>?title=Stock+below+10&severity=MEDIUM&customId=stock-low"), "")

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.

https://m91-api.msg91.com/s/<token>?title=Need+help+at+the+front+desk&severity=HIGH&respond=On+my+way,Seen

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.

https://m91-api.msg91.com/s/<token>?title=Server+room+door+open&severity=HIGH

Devices

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"}]}'
await fetch(process.env.M91_SEND_LINK, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    title: 'Payments webhook failing',
    description: `${count} failures since ${since.toISOString()}`,
    severity: 'CRITICAL',
    customId: 'payments-webhook-failing',
    responseOptions: [
      { label: "I'll handle it", effect: 'SHARED' },
      { label: 'Seen', effect: 'PERSONAL' },
    ],
  }),
});
requests.post(os.environ["M91_SEND_LINK"], json={
    "title": "Nightly export did not run",
    "severity": "HIGH",
    "customId": "nightly-export",
    "responseOptions": [
        {"label": "I'll handle it", "effect": "SHARED"},
        {"label": "Seen", "effect": "PERSONAL"},
    ],
})

AI agents that pause for a human

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

  1. 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.
  2. Get the answer. Use a callbackUrl (best), or poll GET <link>/alerts/<customId> until decision is set.
  3. 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 codeWhy it earns an alert
A webhook handler that catches and logsPayments and signups fail here, and the log is read tomorrow
A scheduled job with a deadlineNothing reports a job that simply did not run
A queue or backlog past a thresholdThe system is degrading while everything still returns 200
A dead-letter queue receiving anythingBy definition nothing else will retry it
A payment, refund or payout failingMoney, and a customer already waiting
A health check that failed N timesThe difference between a blip and an outage
A deploy or migration needing a human gateThe pipeline is blocked until a person decides
An agent or automation about to do something irreversibleSee AI agents above
Auth or security: repeated failures, a new admin, a key used from nowhereNobody is watching the log at 2am
A third-party integration returning errors in a rowYour 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:

  1. A person is needed. Not a retry, not a dashboard, not a log line.
  2. Now matters. Waiting until morning makes it worse.
  3. 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 linkOne person: Platform API
Use it whenThe people are a fixed group in a channelYour product discovers its users at runtime
CredentialSend link token in the URL (/s/sk_…)API key, Authorization: Bearer m91sk_…
Comes fromA channel, in the M91 appThe M91 app: Settings → API keys
AlertsEveryone in that channelAny phone number you invited and who accepted
Can create channelsNoYes

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())
Response
{
  "success": true,
  "data": {
    "ok": true,
    "channel": {
      "id": "6712ab…",
      "name": "Payments",
      "visibility": "PRIVATE"
    },
    "sender": {
      "id": "6712ac…",
      "label": "Grafana"
    }
  }
}
{
  "success": false,
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Invalid sender token"
  }
}

Send an alert (GET)

GET/s/{token}

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
true false 1 0 yes no on off. 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())
Response
{
  "success": true,
  "data": {
    "_id": "6703f1a2c8e4b90012ab34cd",
    "channelId": "6712ab…",
    "channelName": "Payments",
    "customId": "payment-4821",
    "severity": "HIGH",
    "status": "OPEN",
    "isAutoClose": false,
    "createdAt": "2026-10-01T02:10:03.000Z",
    "delivered": 4,
    "unregistered": [],
    "noDevice": 1,
    "pending": 2
  }
}
{
  "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"
        }
      ]
    }
  }
}

Send an alert

POST/s/{token}

Raises an alert with the full payload. This is the main endpoint: one URL, no SDK, no headers except Content-Type.

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.

Body

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.
  • 200 A repeat was suppressed. Nobody was alerted again.
  • 400 VALIDATION_ERROR or MALFORMED_JSON. Unknown fields are rejected.
  • 401 Wrong or revoked token.
  • 404 channel_not_found.
  • 409 CHANNEL_EMPTY. Nobody can receive. People must accept in the app.
  • 413 Body over 1 MB.
  • 429 More than 60 sends a minute. Wait for Retry-After.

Check delivered. It is the number of phones the alert was sent to. unregistered, noDevice and pending say why people were missed. See Delivery.

Content-Type: application/json is required. There is no recipient field: the channel is the recipient list.

Request
curl -X POST "$M91_SEND_LINK" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Payment 4821 failed",
    "description": "Card declined 3 times. Customer is waiting.",
    "severity": "HIGH",
    "customId": "payment-4821",
    "responseOptions": [
      {
        "label": "I am on it",
        "effect": "SHARED"
      },
      {
        "label": "Seen",
        "effect": "PERSONAL"
      }
    ]
  }'
const res = await fetch(process.env.M91_SEND_LINK, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    "title": "Payment 4821 failed",
    "description": "Card declined 3 times. Customer is waiting.",
    "severity": "HIGH",
    "customId": "payment-4821",
    "responseOptions": [
      {
        "label": "I am on it",
        "effect": "SHARED"
      },
      {
        "label": "Seen",
        "effect": "PERSONAL"
      }
    ]
  }),
});
const result = await res.json();
import os, requests

res = requests.post(
    os.environ["M91_SEND_LINK"],
    json={
    "title": "Payment 4821 failed",
    "description": "Card declined 3 times. Customer is waiting.",
    "severity": "HIGH",
    "customId": "payment-4821",
    "responseOptions": [
        {
            "label": "I am on it",
            "effect": "SHARED",
        },
        {
            "label": "Seen",
            "effect": "PERSONAL",
        },
    ],
},
)
print(res.json())
Response
{
  "success": true,
  "data": {
    "_id": "6703f1a2c8e4b90012ab34cd",
    "channelId": "6712ab…",
    "channelName": "Payments",
    "customId": "payment-4821",
    "severity": "HIGH",
    "status": "OPEN",
    "isAutoClose": false,
    "createdAt": "2026-10-01T02:10:03.000Z",
    "delivered": 4,
    "unregistered": [],
    "noDevice": 1,
    "pending": 2
  }
}
{
  "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())
Response
{
  "success": true,
  "data": {
    "_id": "6703f1a2c8e4b90012ab34cd",
    "customId": "deploy-prod",
    "status": "RESOLVED",
    "severity": "HIGH",
    "title": "Approve deploy to prod?",
    "isAutoClose": false,
    "responseOptions": [
      {
        "label": "Approve",
        "effect": "SHARED"
      },
      {
        "label": "Reject",
        "effect": "SHARED"
      },
      {
        "label": "Seen",
        "effect": "PERSONAL"
      }
    ],
    "attachments": [],
    "responses": [
      {
        "label": "Approve",
        "effect": "SHARED",
        "optionIndex": 0,
        "at": "2026-10-01T03:14:22.000Z",
        "by": "…",
        "note": "checked the migration, go ahead",
        "attachments": [
          {
            "id": "3a24…",
            "url": "https://…/photo.jpg",
            "name": "photo.jpg",
            "type": "image/jpeg",
            "size": 48213
          }
        ]
      }
    ],
    "pending": 1,
    "decision": {
      "label": "Approve",
      "effect": "SHARED",
      "at": "2026-10-01T03:14:22.000Z",
      "by": "…"
    },
    "resolvedAt": "2026-10-01T03:20:41.000Z"
  }
}
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "alert_not_found"
  }
}

Close an alert

POST/s/{token}/close

Closes an open alert. Do it when the problem clears. Closing also re-arms the customId, so the next failure can alert again.

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.

Body

customId
string
Your id for the alert. Send this or alertId.
alertId
string
The _id from the send response.

Responses

  • 200 Closed.
  • 400 Neither customId nor alertId was sent.
  • 403 Already resolved. Treat as success.
  • 404 Nothing open with that id. Treat as success.

Scoped to the token’s channel. One link cannot close another channel’s alerts.

Request
curl -X POST "$M91_SEND_LINK/close" \
  -H "Content-Type: application/json" \
  -d '{
    "customId": "payment-4821"
  }'
const res = await fetch(process.env.M91_SEND_LINK + "/close", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    "customId": "payment-4821"
  }),
});
const result = await res.json();
import os, requests

res = requests.post(
    os.environ["M91_SEND_LINK"] + "/close",
    json={
    "customId": "payment-4821",
},
)
print(res.json())
Response
{
  "success": true,
  "data": {
    "_id": "6703f1a2c8e4b90012ab34cd",
    "status": "RESOLVED"
  }
}
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "alert_not_found"
  }
}
Platform API

Get key info

GET/api/v1/me

Returns the key’s label and its owner. Use it to check a key is valid.

Authorization Header Authorization: Bearer m91sk_…

Responses

  • 200 The key.
  • 401 Missing or revoked key.
Request
curl "https://m91-api.msg91.com/api/v1/me" \
  -H "Authorization: Bearer $M91_API_KEY"
const res = await fetch("https://m91-api.msg91.com/api/v1/me", {
  headers: { "Authorization": `Bearer ${process.env.M91_API_KEY}` },
});
const result = await res.json();
import os, requests

res = requests.get(
    "https://m91-api.msg91.com/api/v1/me",
    headers={"Authorization": f"Bearer {os.environ['M91_API_KEY']}"},
)
print(res.json())
Response
{
  "success": true,
  "data": {
    "label": "CRM backend",
    "owner": "…"
  }
}
{
  "success": false,
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Invalid API key"
  }
}

Add a recipient

POST/api/v1/recipients

Invites one person so you can alert them directly. Idempotent: call it on every login if you like.

Authorization Header Authorization: Bearer m91sk_…

Body

phone
stringrequired
Digits only, 8–15, country code included, no +. Example 919876543210.
label
string
Up to 60 characters. A name for the person.

state values

pending
state
Invited, has not answered. Wait. Do not retry.
accepted
state
Can be alerted. Only accepted recipients can.
declined
state
Terminal. No API can re-invite. Only the person can undo it in their app.

Responses

  • 200 The recipient and their state.
  • 400 Invalid phone or label.
  • 401 Bad key.

Until the person accepts in the M91 app, reachable is false and an alert reaches nobody.

Request
curl -X POST "https://m91-api.msg91.com/api/v1/recipients" \
  -H "Authorization: Bearer $M91_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "919876543210",
    "label": "Sales rep"
  }'
const res = await fetch("https://m91-api.msg91.com/api/v1/recipients", {
  method: "POST",
  headers: { "Authorization": `Bearer ${process.env.M91_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "phone": "919876543210",
    "label": "Sales rep"
  }),
});
const result = await res.json();
import os, requests

res = requests.post(
    "https://m91-api.msg91.com/api/v1/recipients",
    headers={"Authorization": f"Bearer {os.environ['M91_API_KEY']}"},
    json={
    "phone": "919876543210",
    "label": "Sales rep",
},
)
print(res.json())
Response
{
  "success": true,
  "data": {
    "phone": "919876543210",
    "state": "pending",
    "reachable": false
  }
}

Remove a recipient

DELETE/api/v1/recipients/{phone}

Stops you being able to alert this person.

Authorization Header Authorization: Bearer m91sk_…

Path parameters

phone
stringrequired
Digits only, 8–15, country code included, no +. Example 919876543210.

Responses

  • 200 Removed.
  • 401 Bad key.
Request
curl -X DELETE "https://m91-api.msg91.com/api/v1/recipients/919876543210" \
  -H "Authorization: Bearer $M91_API_KEY"
const res = await fetch("https://m91-api.msg91.com/api/v1/recipients/919876543210", {
  method: "DELETE",
  headers: { "Authorization": `Bearer ${process.env.M91_API_KEY}` },
});
const result = await res.json();
import os, requests

res = requests.delete(
    "https://m91-api.msg91.com/api/v1/recipients/919876543210",
    headers={"Authorization": f"Bearer {os.environ['M91_API_KEY']}"},
)
print(res.json())
Response
{
  "success": true,
  "data": {
    "phone": "919876543210",
    "removed": true
  }
}

Send a direct alert

POST/api/v1/alerts

Alerts one person by phone number. It is a channel with one member, so responses, escalation and repeats behave like a send-link alert.

Authorization Header Authorization: Bearer m91sk_…

Body

phone
stringrequired
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.
  • 404 not_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.

Request
curl -X POST "https://m91-api.msg91.com/api/v1/alerts" \
  -H "Authorization: Bearer $M91_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "919876543210",
    "title": "Payment 4821 failed",
    "description": "Card declined 3 times. Customer is waiting.",
    "severity": "HIGH",
    "customId": "payment-4821",
    "responseOptions": [
      {
        "label": "I am on it",
        "effect": "SHARED"
      },
      {
        "label": "Seen",
        "effect": "PERSONAL"
      }
    ]
  }'
const res = await fetch("https://m91-api.msg91.com/api/v1/alerts", {
  method: "POST",
  headers: { "Authorization": `Bearer ${process.env.M91_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "phone": "919876543210",
    "title": "Payment 4821 failed",
    "description": "Card declined 3 times. Customer is waiting.",
    "severity": "HIGH",
    "customId": "payment-4821",
    "responseOptions": [
      {
        "label": "I am on it",
        "effect": "SHARED"
      },
      {
        "label": "Seen",
        "effect": "PERSONAL"
      }
    ]
  }),
});
const result = await res.json();
import os, requests

res = requests.post(
    "https://m91-api.msg91.com/api/v1/alerts",
    headers={"Authorization": f"Bearer {os.environ['M91_API_KEY']}"},
    json={
    "phone": "919876543210",
    "title": "Payment 4821 failed",
    "description": "Card declined 3 times. Customer is waiting.",
    "severity": "HIGH",
    "customId": "payment-4821",
    "responseOptions": [
        {
            "label": "I am on it",
            "effect": "SHARED",
        },
        {
            "label": "Seen",
            "effect": "PERSONAL",
        },
    ],
},
)
print(res.json())
Response
{
  "success": true,
  "data": {
    "_id": "6703f1a2c8e4b90012ab34cd",
    "channelId": "6712ab…",
    "channelName": "Payments",
    "customId": "payment-4821",
    "severity": "HIGH",
    "status": "OPEN",
    "isAutoClose": false,
    "createdAt": "2026-10-01T02:10:03.000Z",
    "delivered": 4,
    "unregistered": [],
    "noDevice": 1,
    "pending": 2
  }
}
{
  "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": "NOT_FOUND",
    "message": "not_a_recipient"
  }
}
{
  "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"
        }
      ]
    }
  }
}

Read a direct alert

GET/api/v1/alerts/{id}

Reads what the person decided. Same response as Read an alert.

Authorization Header Authorization: Bearer m91sk_…

Path parameters

id
stringrequired
The customId or _id.

Query parameters

phone
stringrequired
The recipient the alert was sent to.

Responses

  • 200 The alert, including decision.
  • 404 No such alert for that recipient.
Request
curl -G "https://m91-api.msg91.com/api/v1/alerts/payment-4821" \
  -H "Authorization: Bearer $M91_API_KEY" \
  --data-urlencode "phone=919876543210"
const url = new URL("https://m91-api.msg91.com/api/v1/alerts/payment-4821");
url.searchParams.set("phone", "919876543210");
const res = await fetch(url, {
  headers: { "Authorization": `Bearer ${process.env.M91_API_KEY}` },
});
const result = await res.json();
import os, requests

res = requests.get(
    "https://m91-api.msg91.com/api/v1/alerts/payment-4821",
    headers={"Authorization": f"Bearer {os.environ['M91_API_KEY']}"},
    params={
    "phone": "919876543210",
},
)
print(res.json())
Response
{
  "success": true,
  "data": {
    "_id": "6703f1a2c8e4b90012ab34cd",
    "customId": "deploy-prod",
    "status": "RESOLVED",
    "severity": "HIGH",
    "title": "Approve deploy to prod?",
    "isAutoClose": false,
    "responseOptions": [
      {
        "label": "Approve",
        "effect": "SHARED"
      },
      {
        "label": "Reject",
        "effect": "SHARED"
      },
      {
        "label": "Seen",
        "effect": "PERSONAL"
      }
    ],
    "attachments": [],
    "responses": [
      {
        "label": "Approve",
        "effect": "SHARED",
        "optionIndex": 0,
        "at": "2026-10-01T03:14:22.000Z",
        "by": "…",
        "note": "checked the migration, go ahead",
        "attachments": [
          {
            "id": "3a24…",
            "url": "https://…/photo.jpg",
            "name": "photo.jpg",
            "type": "image/jpeg",
            "size": 48213
          }
        ]
      }
    ],
    "pending": 1,
    "decision": {
      "label": "Approve",
      "effect": "SHARED",
      "at": "2026-10-01T03:14:22.000Z",
      "by": "…"
    },
    "resolvedAt": "2026-10-01T03:20:41.000Z"
  }
}
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "alert_not_found"
  }
}

Close a direct alert

POST/api/v1/alerts/close

Closes an open direct alert.

Authorization Header Authorization: Bearer m91sk_…

Body

phone
stringrequired
Digits only, 8–15, country code included, no +. Example 919876543210.
customId
string
The alert’s customId. Send this or alertId.
alertId
string
The _id from the send response.

Responses

  • 200 Closed.
  • 400 Neither customId nor alertId was sent.
  • 403 Already resolved. Treat as success.
  • 404 Nothing to close. Treat as success.
Request
curl -X POST "https://m91-api.msg91.com/api/v1/alerts/close" \
  -H "Authorization: Bearer $M91_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "919876543210",
    "customId": "payment-4821"
  }'
const res = await fetch("https://m91-api.msg91.com/api/v1/alerts/close", {
  method: "POST",
  headers: { "Authorization": `Bearer ${process.env.M91_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "phone": "919876543210",
    "customId": "payment-4821"
  }),
});
const result = await res.json();
import os, requests

res = requests.post(
    "https://m91-api.msg91.com/api/v1/alerts/close",
    headers={"Authorization": f"Bearer {os.environ['M91_API_KEY']}"},
    json={
    "phone": "919876543210",
    "customId": "payment-4821",
},
)
print(res.json())
Response
{
  "success": true,
  "data": {
    "_id": "6703…",
    "status": "RESOLVED"
  }
}
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "alert_not_found"
  }
}

Create a channel

POST/api/v1/channels

Creates a team channel and returns its send link. Use it when your platform creates one channel per customer team.

Authorization Header Authorization: Bearer m91sk_…

Body

name
stringrequired
Up to 80 characters.
alertOwner
boolean
Default true. Set false so the key’s owner is not added and paged for every channel.

Responses

  • 201 The new channel.
  • 400 Missing or too long name.

Not idempotent. It takes no id from you, so calling it twice makes two channels. Store the _id or sendLink against your own team straight away.

An API key can create and staff a channel but cannot alert it. Alerting a team is the send link’s job.

Request
curl -X POST "https://m91-api.msg91.com/api/v1/channels" \
  -H "Authorization: Bearer $M91_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Corp on-call",
    "alertOwner": false
  }'
const res = await fetch("https://m91-api.msg91.com/api/v1/channels", {
  method: "POST",
  headers: { "Authorization": `Bearer ${process.env.M91_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "name": "Acme Corp on-call",
    "alertOwner": false
  }),
});
const result = await res.json();
import os, requests

res = requests.post(
    "https://m91-api.msg91.com/api/v1/channels",
    headers={"Authorization": f"Bearer {os.environ['M91_API_KEY']}"},
    json={
    "name": "Acme Corp on-call",
    "alertOwner": False,
},
)
print(res.json())
Response
{
  "success": true,
  "data": {
    "id": "6712ab…",
    "name": "Acme Corp on-call",
    "sendLink": "https://m91-api.msg91.com/s/sk_…"
  }
}

List channels

GET/api/v1/channels

Returns every channel you can send to. Not paginated.

Authorization Header Authorization: Bearer m91sk_…

Responses

  • 200 An array of channels.
Request
curl "https://m91-api.msg91.com/api/v1/channels" \
  -H "Authorization: Bearer $M91_API_KEY"
const res = await fetch("https://m91-api.msg91.com/api/v1/channels", {
  headers: { "Authorization": `Bearer ${process.env.M91_API_KEY}` },
});
const result = await res.json();
import os, requests

res = requests.get(
    "https://m91-api.msg91.com/api/v1/channels",
    headers={"Authorization": f"Bearer {os.environ['M91_API_KEY']}"},
)
print(res.json())
Response
{
  "success": true,
  "data": [
    {
      "_id": "6712ab…",
      "name": "Acme Corp on-call",
      "sendLink": "https://m91-api.msg91.com/s/sk_…"
    }
  ]
}

Add channel members

POST/api/v1/channels/{id}/members

Invites people into a channel by phone number. Each person still has to accept in the app.

Authorization Header Authorization: Bearer m91sk_…

Path parameters

id
stringrequired
The channel id.

Body

phones
string[]required
Phone numbers, digits only, 8–15, no +. Up to 500 per call.

Responses

  • 200 Who was added.
  • 404 Channel not found or not yours.
Request
curl -X POST "https://m91-api.msg91.com/api/v1/channels/6712ab…/members" \
  -H "Authorization: Bearer $M91_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phones": [
      "919876543210",
      "919812345678"
    ]
  }'
const res = await fetch("https://m91-api.msg91.com/api/v1/channels/6712ab…/members", {
  method: "POST",
  headers: { "Authorization": `Bearer ${process.env.M91_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "phones": [
      "919876543210",
      "919812345678"
    ]
  }),
});
const result = await res.json();
import os, requests

res = requests.post(
    "https://m91-api.msg91.com/api/v1/channels/6712ab…/members",
    headers={"Authorization": f"Bearer {os.environ['M91_API_KEY']}"},
    json={
    "phones": ["919876543210", "919812345678"],
},
)
print(res.json())
Response
{
  "success": true,
  "data": {
    "added": [
      "919812345678"
    ],
    "alreadyMembers": [
      "919876543210"
    ],
    "invited": [
      "919812345678"
    ]
  }
}

Remove a channel member

DELETE/api/v1/channels/{id}/members/{phone}

Removes one person from a channel.

Authorization Header Authorization: Bearer m91sk_…

Path parameters

id
stringrequired
The channel id.
phone
stringrequired
Digits only, 8–15, country code included, no +. Example 919876543210.

Responses

  • 200 Removed.
  • 404 Channel not found or not yours.
Request
curl -X DELETE "https://m91-api.msg91.com/api/v1/channels/6712ab…/members/919876543210" \
  -H "Authorization: Bearer $M91_API_KEY"
const res = await fetch("https://m91-api.msg91.com/api/v1/channels/6712ab…/members/919876543210", {
  method: "DELETE",
  headers: { "Authorization": `Bearer ${process.env.M91_API_KEY}` },
});
const result = await res.json();
import os, requests

res = requests.delete(
    "https://m91-api.msg91.com/api/v1/channels/6712ab…/members/919876543210",
    headers={"Authorization": f"Bearer {os.environ['M91_API_KEY']}"},
)
print(res.json())
Response
{
  "success": true,
  "data": {
    "phone": "919876543210",
    "state": "REMOVED"
  }
}
Alert behavior

What goes in an alert

An alert is a short message with buttons. Only the title is required. Add the other parts when the situation needs them.

PartAdd it whenField
The headlinealwaystitle
The detailsthe person has to decide something and the title is not enoughdescription
How urgentalways. The default never phones anyoneseverity
The buttonsthe person has to do something, which is almost every alertresponseOptions
Proofa graph, screenshot or document would make it obviousattachments
A way backyour software is waiting for the answercallbackUrl

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.

LevelThink of it asWhat M91 does
LOWFYIRings once. Does not chase.
MEDIUM (default)Look at this soonRings again for 2 minutes. Never phones anyone.
HIGHNeeds a person nowKeeps ringing for 18 minutes. Phones only if nobody has responded and nobody has even opened it.
CRITICALMoney or safety is at riskKeeps 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:

TypeIt meansWhat 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 wantSend these buttons
One person to take it onone SHARED button, plus PERSONAL ones like Seen
A yes or no decisiontwo SHARED buttons: Approve and Reject
A reason when the answer is nomake the no button ask for a note: "required": ["note"]
Everyone to confirmonly 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 happensDoes it close?
Someone taps a SHARED buttononly if isAutoClose is true
Everyone tapped a PERSONAL button, and there was no SHARED buttonyes
No buttons ([]) and isAutoClose: trueyes, as soon as it is delivered
No buttons and no isAutoClosenever. 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 sendWhat happens
No buttons and no isAutoCloseNobody can answer and nothing can close it
Every button asks for something, and other is falseNobody can answer at all
MEDIUM and nobody looksRings for 2 minutes, then goes quiet. No call, ever
HIGH and somebody opens itNo call is made, because somebody is already looking
A SHARED button and isAutoClose: falseOne person takes it, the rest are released, and it stays open until closed
callbackUrl with PERSONAL buttons on 300 people300 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.

{ "event": "answered", "at": "2026-10-01T12:14:09.535Z",
  "alert": { "id": "…", "customId": "deploy-4821", "title": "…" },
  "response": { "label": "Approve", "effect": "SHARED", "by": { "id": "…", "name": "Aryan" },
                "at": "…", "note": "go ahead", "attachments": [] },
  "idempotencyKey": "<alertId>-<userId>-<generation>" }
  • 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.

StatuscodeMeaningWhat to do
400VALIDATION_ERRORA field is invalid. details.fields names it. Unknown fields are rejected.Fix that field.
400MALFORMED_JSONBody is not JSON.Fix quoting. Send Content-Type: application/json.
401UNAUTHORIZEDToken or key is missing, wrong, forged or revoked.Copy it again. Do not retry.
403FORBIDDENNot allowed. On close, usually already resolved.On close, treat as success.
404NOT_FOUNDAbsent, or not yours. The two look the same on purpose.Check the id and channel.
409CHANNEL_EMPTYNobody can receive. details.unregistered lists invited numbers with no account.People must install, sign in and accept.
413PAYLOAD_TOO_LARGEBody over 1 MB.Send a summary and a reference.
429RATE_LIMITEDOver 60 sends a minute per link. Retry-After is always set.Wait, then add a customId.
500INTERNAL_ERRORUnexpected.Retry with backoff.

Limits

ItemLimit / default
title5–2000 characters
description2000 characters
customId200 characters
Response option label24 characters
Response options20 in JSON, 6 in a link
Channel name80 characters
Key or sender label60 characters
phone8–15 digits, no +
Members per invite call500
Request body1 MB
Send rate60 per minute per send link
Duplicate payload window5 seconds
Default severityMEDIUM
Dialling severitiesHIGH, CRITICAL
Escalation delay45 seconds
People per wave10
Calls per alert50
Calls per person1 per 5 minutes
Retentionclosed 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”.
On this page