# M91 API

The contract behind **an interface for any software or AI to reach a human**:
every endpoint, every field, every response shape, every limit.

If you want to know what M91 *is* rather than how to call it, read
[the index](https://m91-api.msg91.com/api/public/index.md) and
[what goes in an alert](https://m91-api.msg91.com/api/public/fields.md) first — this page
assumes you already know.

## [Two credentials](#credentials)

| | Send link | API key |
|---|---|---|
| Looks like | `https://m91-api.msg91.com/s/sk_…` | `m91sk_…` |
| Where it comes from | a channel, in the M91 app | the M91 app, Settings → API keys |
| Alerts | everyone in that one channel | any phone number you have invited |
| Held by | the system doing the sending | your backend |
| Can create channels | no | yes |

**Use a send link** when the people are a fixed team. It is a URL with no
headers, called over plain HTTPS or through the SDK.

**Use an API key** when your product alerts its own users, and you do not know
at build time who they are.

A send link is a credential in a URL. Anyone holding it can alert that channel.
Keep it where you keep passwords, and never log the path of a request to it.

---

# Send link

No authentication beyond the token in the path. Base: `https://m91-api.msg91.com/s/<token>`.

## [GET — the link](#get)

```
GET /s/<token>?title=Payments+API+is+down&severity=CRITICAL&respond=On+it,Seen
```

Opening this URL raises an alert. It works from a browser, a phone shortcut, a
spreadsheet formula, a cron job, or anything with a "URL to call" field.

**`GET /s/<token>` with no `title` raises nothing.** It answers with the
channel's name and the link's own label, and is the way to check a link is live. This is
a security control, not a convenience: the token is in the URL, so link
previews, crawlers and mail scanners fetch it without anybody deciding to.

| Parameter | Required | Value |
|---|---|---|
| `title` | **yes** | 5–2000 characters. Without it, nothing is raised. |
| `description` | no | up to 2000 characters |
| `severity` | no | `LOW` `MEDIUM` `HIGH` `CRITICAL`. Defaults to `MEDIUM`. |
| `respond` | no | comma-separated labels, up to 6, each up to 24 characters. See below. |
| `isAutoClose` | no | `true` `false` `1` `0` `yes` `no` `on` `off` |
| `customId` | no | up to 200 characters. See [Repeats](#repeats). |

**`respond=I am on it,Seen`** — the first label is `SHARED`, the rest are
`PERSONAL`. That is the shape almost every alert has: one answer that takes
responsibility for the whole group, and some that do not. A `!` suffix forces
`SHARED` on any label — `respond=On it,Seen!` makes both take responsibility.

Only these six parameters are accepted; anything else is rejected. The link
cannot carry `other`, `attachments` or `callbackUrl` — use `POST` for those.

A browser gets an HTML page saying what happened. Anything else gets JSON:
`201` for a new alert, `200` for a repeat.

## [POST — the full payload](#post)

```
POST /s/<token>
Content-Type: application/json
```

Everything the link can carry, plus response options with explicit effects.

| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
| `title` | string | **yes** | — | 5–2000 characters. The line somebody reads at 3am. (`summary` is accepted as an alias.) |
| `description` | string | no | — | Up to 2000 characters. What happened, and what is needed to act. |
| `severity` | enum | no | `MEDIUM` | `LOW` `MEDIUM` `HIGH` `CRITICAL`. Decides how hard M91 chases an answer. See [Severity](#severity). |
| `responseOptions` | array | no | none | Up to 20 `{label, effect, required?, optional?}`. Each label up to 24 characters, and labels must differ. `effect` defaults to `SHARED`. See [Responses](#responses). |
| `other` | boolean | no | `true` | Whether people may reply in their own words instead of picking an option. See [Responses](#responses). |
| `attachments` | array | no | — | `https` links, each up to 2000 characters, shown with the alert. No count limit. See [Attachments](#attachments). |
| `callbackUrl` | string | no | — | An `https` URL M91 POSTs to when somebody answers and when the alert ends. See [Callbacks](#callbacks). |
| `isAutoClose` | boolean | no | `false` | Whether the alert closes itself. See [When an alert ends](#ends). |
| `customId` | string | no | — | Up to 200 characters. Your own id for the alert. See [Repeats](#repeats). |

Unknown fields are rejected rather than ignored, so a typo fails loudly instead
of silently doing nothing. Request bodies are limited to 1 MB.

Answers `201` for a new alert and `200` for a repeat (see [Repeats](#repeats)).
`409 CHANNEL_EMPTY` means nobody in the channel can receive it yet, with the
numbers that have not installed the app in `error.details.unregistered`. `429`
means too many sends: wait the `Retry-After` seconds (60) and try again.

### [Responses](#responses)

**What it is.** The buttons the woken person sees. They pick one, once.

**Why it exists.** An alert that only says "something is wrong" makes every
recipient phone each other to find out who is dealing with it. `responseOptions`
turns the alert itself into the coordination: one person taps, and everybody
else's phone stops. The options are how the sender says *what answering means*.

**Write labels as the answer, in the first person, as a person would say it.**
The label is read in the half-second before a thumb lands on it, by somebody who
was asleep. `"I'll handle it"`, `"Approve"`, `"Already fixed"`. Not `"OK"`,
`"Option 1"`, `"ACK"`, or a restatement of the alert title.

```json
"responseOptions": [
  { "label": "I'll handle it", "effect": "SHARED" },
  { "label": "Seen",           "effect": "PERSONAL" }
]
```

`SHARED` — the person is taking responsibility for the alert. It goes away for
everyone else in the channel.

`PERSONAL` — the person is not taking responsibility. The alert stays with
everyone else.

One response per person, and it is final. A `SHARED` response cannot be
superseded by somebody answering later.

#### Which shape do I want?

Almost every alert is one of four. Find yours here rather than inventing a
fifth.

| You want | Send | Why |
|---|---|---|
| **Somebody to take this on** — an outage, a failing job | one `SHARED` option, plus `PERSONAL` ones like `"Seen"` | the first person to tap owns it and everyone else is released |
| **A decision from one person** — a deploy, a refund, an agent asking permission | two `SHARED` options, e.g. `"Approve"` / `"Reject"` | either answer settles it; you read `label` to learn which |
| **Every single person to confirm** — a drill, a policy acknowledgement | only `PERSONAL` options | the alert closes when the last person has answered |
| **Nothing — just interrupt them** | `"responseOptions": []` **and** `"isAutoClose": true` | see [When an alert ends](#ends) |

**The most common mistake** is leaving `responseOptions` out when you meant the
last row. Omitted is not the same as `[]`. Omitted means *nobody can answer and
nothing can close it*, and the alert sits open forever. If you want a pure
notification, send the empty array explicitly and `isAutoClose: true`.

**The second most common mistake** is a single `PERSONAL` option like
`"Acknowledge"` on an alert that needs somebody to act. Every recipient can tap
it, nobody is released, nobody has claimed anything, and you have learned only
that people saw it. If one person taking it on should stop the others' phones,
that option is `SHARED`.

#### Replies in the person's own words

```json
"other": false
```

By default M91 adds its own **"Something else"** to your options: the person
types a sentence instead of picking. It is always `PERSONAL` — writing a reply
never claims the alert — so it can never conflict with your `SHARED` option.

Leave it on. It is the escape hatch for the case you did not anticipate, and it
is where the useful text comes from: *"power supply died, ordering a new one"*.
Set `other: false` only when the answer must be one of a fixed set, such as a
compliance check where free text would be a liability.

#### Asking for information with the answer

An option can require or invite a note, a file, or both:

```json
"responseOptions": [
  {
    "label": "Fixed it",
    "effect": "SHARED",
    "required": ["note"],
    "optional": ["attachment"]
  },
  { "label": "Seen", "effect": "PERSONAL" }
]
```

**What it is.** `required` is what the person cannot send the answer without.
`optional` is offered and skippable. Both take `"note"`, `"attachment"`, or
both. A field may not appear in both lists for the same option.

**Why it exists.** "Fixed it" with no explanation is the answer that generates
the next three questions. Asking at the moment of answering is the only time the
person still has the context — a photo of the tripped breaker, the sentence
explaining what they actually did. An hour later they are asleep again.

**When to use it.** When the answer is worthless without detail: a repair that
needs evidence, a rejection that needs a reason, an approval that needs a
reference number. When an AI agent will act on the reply and needs a value, not
just a button press.

**When not to.** Never on every option. Every requirement you add is time
between a ringing phone and the alert being handled, and at 3am that is the
thing this product exists to save. There must always be a one-tap way out — and
M91 enforces nothing here, so it is on you. If you mark every option `required`
and also set `other: false`, nobody can answer at all.

Answers come back on `GET /s/<token>/alerts/<id>` and on your
[callback](#callbacks) as `note` and `attachments`, always present, `null` and
`[]` when not given.

### [Severity](#severity)

**What it is.** How hard M91 chases an answer. It is not a label for how bad the
problem is — it is an instruction about what to do to people's evenings.

**Why it exists.** Every alert is worth interrupting someone for, or it should
not be an alert. What differs is how long M91 keeps trying, and whether it is
allowed to move from notifications to a phone call.

| | Rings again | For how long | Phones people |
|---|---|---|---|
| `LOW` | no | once | never |
| `MEDIUM` *(default)* | yes | 2 minutes | **never** |
| `HIGH` | yes | 18 minutes | at 3 minutes, only if nobody has responded **and** nobody has opened the alert in the last 3 minutes |
| `CRITICAL` | yes | 60 minutes | yes, in pairs, and it alarms the channel owner after 15 minutes |

**Every severity rings at full volume and breaks through silent mode.** That is
what M91 is. Severity decides what happens when the first ring is ignored.

**Choose by what happens if nobody answers.**

- `LOW` — you want it on their phone, you do not want it chased. Deploy
  finished. Nightly job succeeded.
- `MEDIUM` — the default, and right for most things. Somebody should look, soon.
  A queue is backing up. A customer is waiting.
- `HIGH` — this needs a person now, but a dead channel is the only thing worth
  phoning over. M91 watches whether anyone has even *opened* the alert; if
  people are looking at it, the push worked and no call is placed.
- `CRITICAL` — money or safety is draining while this is unanswered. Calls are
  placed in pairs 90 seconds apart, which is what gets through iOS "Allow
  Repeated Calls" and Android's repeat-caller exception on a silenced phone.

**Do not send everything as `CRITICAL`.** It is the one failure that cannot be
fixed later in code: a team phoned twice for something that could have waited
learns to ignore the phone, and the next genuine `CRITICAL` reaches nobody.

**A response stops all of it immediately** — the chain ends the moment anybody
answers, and opening the alert pauses that person's re-rings for three minutes
so somebody already working on it is not shouted at.

**If nobody ever answers**, M91 stops chasing and tells the channel owner. The
alert stays open — "we stopped trying" is not "this is handled".

### [Attachments](#attachments)

```json
"attachments": [
  "https://status.example.com/graphs/error-rate.png",
  "https://example.com/runbooks/payments.pdf"
]
```

**What it is.** `https` links to files, shown inside the alert. Images
render inline; PDFs, audio and video become a row that opens in the phone's own
viewer.

**Why it exists.** The person you woke has to decide, in seconds, whether this
needs them. A title can say "error rate is up". A graph shows *how far* up. The
attachment is usually what turns a page-and-investigate into a
look-and-know — or into a confident "that's fine, going back to sleep".

**When to use it.** A graph of the metric that broke. The screenshot your test
run captured. The runbook for this exact condition. The photo the sensor took.

**Links only, never file uploads.** Every way of sending an alert has a URL
field; almost none can attach a file. So the API takes a URL.

**M91 shows your link; it does not keep a copy.** It used to fetch and store each
one, so an alert survived the link dying — that was removed deliberately, because
it made M91 the host of other people's files, with the storage, retention and
responsibility that carries. **The link has to stay alive for the alert to keep
showing the file.** Only what a RESPONDER attaches is stored, because that is
content M91 collected itself and there is nowhere else for it to live.

**The link must open on the recipient's phone without a login.** M91 stores the
link as given and never fetches it, so it is the person's phone that opens it.
That is the one thing that catches people out: a link to your internal Grafana
works in your browser, because you are logged in, and shows nothing on a phone
that is not. A dead link never stops the alert — it still goes out.

A signed URL works if it lives as long as the alert might be read, not just for
a few seconds.

### [Callbacks](#callbacks)

```json
"callbackUrl": "https://your-app.example.com/hooks/m91"
```

**What it is.** An `https` URL M91 POSTs to twice: once when somebody answers,
once when the alert ends.

**Why it exists.** Without it, the only way to learn what a human decided is to
poll `GET /alerts/<id>` until it changes. Every integration that needs the answer
was writing the same polling loop, and each one picked its own interval — so
either the system reacted slowly or it hammered us. A callback turns "wait for a
human" into an ordinary asynchronous step.

**When to use it.** Whenever a machine is waiting on the answer. An AI agent
that asked for approval and must not proceed without it. A deploy pipeline
paused on a human gate. A ticketing system that should record who took it. A
refund that executes only on "Approve".

**When not to.** If nobody is waiting on the answer, leave it out. A human
reading the reply in the app needs no callback.

Two events, and you will get both:

| `event` | Fired when | Carries |
|---|---|---|
| `answered` | one person responds | who, which `label`, its `effect`, their `note` and `attachments` |
| `closed` | the alert ends, however it ended | the final state |

```json
{
  "event": "answered",
  "at": "2026-10-01T03:14:22.000Z",
  "alert": { "id": "6703f1a2c8e4b90012ab34cd", "customId": "deploy-4821", "title": "Approve deploy 4821?" },
  "response": {
    "label": "Approve",
    "effect": "SHARED",
    "by": { "id": "…", "name": "Aryan" },
    "at": "2026-10-01T03:14:22.000Z",
    "note": "checked the migration, go ahead",
    "attachments": []
  },
  "idempotencyKey": "6703f1a2c8e4b90012ab34cd-…-0"
}
```

`note` and `attachments` are always present — `null` and `[]` when the person
gave none — so you never have to discover whether a key exists.

**Treat a callback as a signal, then read the answer back.** Every request
carries a timestamp and an HMAC-SHA256 signature over `"<timestamp>.<raw body>"`,
keyed with the send link's callback secret — but that secret is not shown
anywhere yet, in the app or the API, so you cannot verify it today. Your callback
URL is reachable by anyone who finds it, so never act on the body alone: on any
POST, read the alert back (`GET <link>/alerts/<id>`) and act on the `decision`
that returns.

**Answer 2xx quickly.** M91 retries, so your handler must be idempotent — the
same event may arrive twice. De-duplicate on `idempotencyKey`, which is one per
response (and one for the close), never one per alert.

### [When an alert ends](#ends)

An alert stays open until somebody closes it. `isAutoClose` defaults to `false`.

| What happens | Does the alert close? |
|---|---|
| A `SHARED` response | only if you sent `isAutoClose: true` |
| Everyone has given a `PERSONAL` response, and no option was `SHARED` | yes |
| Delivered, with `responseOptions: []` and `isAutoClose: true` | yes, on delivery |
| Delivered, with no response options and `isAutoClose` false or absent | **no — nothing can settle it** |

That last row is the one to avoid. For a pure notice — something worth
interrupting for, with nothing to answer — send an explicit empty array **and**
`isAutoClose: true`. Omitting `responseOptions` is not the same as sending `[]`:
omitted, the alert can neither be answered nor closed by delivery.

### [Repeats and `customId`](#repeats)

`customId` names the **condition**, not the run. Send the same string every time
the same thing is wrong.

While an alert with that `customId` is open, sending it again alerts nobody and
returns the existing alert. That is what lets a check run every minute without
ringing phones every minute. Once it closes, the same id REOPENS that alert —
the same record, with `reopenCount` incremented and a `REOPENED` entry in its
history, not a new one. A condition that flaps keeps one thread rather than
scattering across a dozen alerts.

`customId` is scoped to the channel, so two channels can both use `disk-full`
without colliding.

Independently of `customId`, byte-identical payloads from one sender inside a
5-second window collapse into one alert — a retry after a timeout does not raise
a second alert. The response is the first alert with `200`, `deduplicated: true`
and `delivered: 0`: nobody was alerted a second time.

## [POST /s/&lt;token&gt;/close](#close)

```json
{ "customId": "disk-full" }
```

or, if you have no `customId`, the `_id` the send returned:

```json
{ "alertId": "6703f1a2c8e4b90012ab34cd" }
```

Send one of the two; if you send both, `customId` wins. Closes that alert.
Closing is what re-arms it: leave it open and every later failure is absorbed in
silence. `404` (`error.code` `NOT_FOUND`) means no alert matches; `403`
(`FORBIDDEN`, message `already_resolved`) means it was closed already. Both mean
there was nothing to close.

## [GET /s/&lt;token&gt;/alerts/&lt;id&gt;](#status)

Who was reached, who answered, and what they chose. `<id>` is either your
`customId` or the `_id` the send returned; it is matched as a `customId` first.

---

# Platform API

For a product alerting its own users by phone number. Base:
`https://m91-api.msg91.com/api/v1`. Every request carries your key:

```
Authorization: Bearer m91sk_…
```

| Method | Path | What it does |
|---|---|---|
| `GET` | `/me` | The key's label and its owner |
| `GET` | `/channels` | Every channel this key can send to |
| `POST` | `/channels` | Create a channel |
| `POST` | `/channels/:id/members` | Add people to it, by phone number |
| `DELETE` | `/channels/:id/members/:phone` | Remove one |
| `POST` | `/recipients` | Invite one person, so you can alert them directly |
| `DELETE` | `/recipients/:phone` | Stop being able to alert them |
| `POST` | `/alerts` | Raise an alert |
| `GET` | `/alerts/:id` | Read what happened |
| `POST` | `/alerts/close` | Close one |

Every phone number is digits only, with the country code and no `+`:
`919876543210`. To alert a channel rather than one person, use that channel's
`sendLink` with the send-link calls above.

| Call | Body or query |
|---|---|
| `POST /channels` | `{ "name": "Payments on-call", "alertOwner": true }` — `name` 1–80 characters. `alertOwner` (default `true`) also makes the key's owner a member. |
| `POST /channels/:id/members` | `{ "phones": ["919876543210"] }` — 1 to 500 numbers |
| `POST /recipients` | `{ "phone": "919876543210", "label": "Payments alerts" }` — `label` is optional, up to 80 characters, and is the name this shows under in their app (default `Direct alerts`) |
| `POST /alerts` | the same body as `POST /s/<token>`, plus `"phone"` |
| `GET /alerts/:id` | `?phone=919876543210` — `:id` is your `customId` or the alert's `_id` |
| `POST /alerts/close` | `{ "phone": "919876543210", "customId": "payment-4821" }`, or `"alertId"` instead of `"customId"` |

`phone` on the alert calls must be written exactly as it was invited. A phone that
was never invited gets `404 not_a_recipient`; one that was invited but has not
accepted gets `409 CHANNEL_EMPTY`.

**A person must accept before they can be alerted.** `POST /recipients` invites
them; until they accept in the M91 app, `reachable` is `false` and an alert to
them reaches nobody. There is no way around this, and it is the step most
integrations forget.

---

## [What comes back](#responses)

**Every response has the same envelope.** Read the field you want from inside
`data`, never from the top level — this is the single most common integration
bug, and it fails silently: a top-level read returns `undefined` on a request
that worked perfectly.

```json
{ "success": true,  "data": { … }, "meta": { … } }
{ "success": false, "error": { "code": "…", "message": "…", "details": …, "docs": "…" } }
```

`error.docs` is a URL to the troubleshooting entry for that exact code. Follow it
rather than guessing.

| Call | `data` is |
|---|---|
| `GET /s/<token>` (no `title`) | `{ ok, channel: { id, name, visibility }, sender: { id, label } }` |
| `POST /s/<token>` | `{ _id, channelId, channelName, customId, severity, status, isAutoClose, responseOptions, createdAt, delivered, unregistered[], warning? }`. A repeat also carries `deduplicated: true` or `reopened: true`, and a `message`. |
| `POST /s/<token>/close` | `{ _id, status }` |
| `GET /s/<token>/alerts/<id>` | `{ _id, customId, status, severity, title, createdAt, isAutoClose, reopenCount, responseOptions[], attachments[], responses[], pending, decision, resolvedAt }` — `pending` is a count |
| `POST /api/v1/recipients` | `{ phone, state, reachable }` — `state` is `pending`, `accepted` or `declined`. `201` when newly invited, `200` otherwise. |
| `DELETE /api/v1/recipients/:phone` | `{ phone, state }` |
| `POST /api/v1/channels` | `{ _id, name, sendLink }` |
| `GET /api/v1/channels` | `[{ _id, name, sendLink }]` — every channel you can send to |
| `POST /api/v1/channels/:id/members` | `{ invited[], already[], declined[], pending[] }` |
| `DELETE /api/v1/channels/:id/members/:phone` | `{ phone, state }` |
| `POST /api/v1/alerts` | same as `POST /s/<token>` |
| `GET /api/v1/alerts/:id` | same as `GET /s/<token>/alerts/<id>` |
| `POST /api/v1/alerts/close` | `{ _id, status }` |
| `GET /api/v1/me` | `{ key: { _id, prefix, label, createdAt }, owner: { phone } }` |

### One response in full

```json
{
  "success": true,
  "data": {
    "_id": "6703f1a2c8e4b90012ab34cd",
    "customId": "payments-500",
    "status": "RESOLVED",
    "severity": "CRITICAL",
    "title": "Payments API returning 500s",
    "createdAt": "2026-10-01T03:12:05.000Z",
    "isAutoClose": false,
    "reopenCount": 0,
    "responseOptions": [ { "label": "I'll handle it", "effect": "SHARED" },
                         { "label": "Seen", "effect": "PERSONAL" } ],
    "attachments": [],
    "decision": { "label": "I'll handle it", "effect": "SHARED",
                  "at": "2026-10-01T03:14:22.000Z", "by": "…" },
    "responses": [
      { "label": "I'll handle it", "effect": "SHARED", "optionIndex": 0,
        "at": "2026-10-01T03:14:22.000Z", "by": "…",
        "note": "restarting the pool",
        "attachments": [ { "id": "3a24…", "url": "https://…/photo.jpg",
                           "name": "photo.jpg", "type": "image/jpeg", "size": 48213 } ] }
    ],
    "pending": 1,
    "resolvedAt": "2026-10-01T03:20:41.000Z"
  }
}
```

`decision` is `null` until a `SHARED` response arrives. A `PERSONAL` response is
never a decision — that is deliberate, so an agent cannot read "Seen" as
"Approved".

`note` and `attachments` are **always present**: `null` and `[]` when the person
gave none. You never have to discover whether a key exists.

## [Limits](#limits)

| | |
|---|---|
| `title` | 5–2000 characters, required |
| `description` | up to 2000 characters |
| `customId` | up to 200 characters |
| Response options | 20 by `POST`, 6 by link, each label up to 24 characters |
| Request body | 1 MB |
| Sends | 60 alerts a minute per link, then `429` with `Retry-After`. Reading an alert back is not limited. |
| Identical-payload window | 5 seconds |

## [When something goes wrong](#errors)

Every error, the exact string to match on, and what to do about it, is in
[Troubleshooting](https://m91-api.msg91.com/api/public/troubleshoot.md). Two that are not
errors and look like them:

- **`success: true` and nobody was alerted.** A repeat inside an open
  `customId`. Working as intended.
- **`reachable: false`.** The person has not accepted their invitation yet.
