# M91: Limitations & Recommendations

Real, confirmed constraints of M91 as it exists today — not hypotheticals.
Each entry is the same four lines:

- **True** — what is actually the case.
- **Why** — the reason it is that way, which is usually the part that stops you
  designing around it wrongly.
- **Do** — the concrete recommendation.
- **Don't** — the mistake this constraint produces.

<!-- Rewritten from running prose into this four-line shape. The prose version
     buried the actionable sentence in the middle of a paragraph of reasoning,
     so the recommendations were read last or not at all. The reasoning is NOT
     cut — CLAUDE.md is explicit that it is load-bearing — it is demoted to the
     "Why" line so the scannable part comes first. If you add an entry, it gets
     all four lines; an entry with no "Why" is one nobody will trust. -->

See [`api.md`](https://m91-api.msg91.com/api/public/api.md) for the API reference and
[`troubleshoot.md`](https://m91-api.msg91.com/api/public/troubleshoot.md) for the error
reference. This document is about designing for M91's real shape, including
the part of it that is made of people.

## 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's control

- **True** — a phone with notification permission denied, no network, or the
  app uninstalled is still counted as a recipient and still makes no sound.
  Failed deliveries are retried, and a queued alert survives a restart of M91's
  own infrastructure.
- **Why** — neither retries nor durability can fix a device that is configured
  not to listen. The counts separate the causes: `unregistered` is invited but
  never signed in; `noDevice` is signed in with no device registered, usually a
  reinstall the person has not opened since.
- **Do** — treat a channel's readiness as something to verify. Have each member
  fire **Send a test alert** from the app once, on their own phone, when they
  join.
- **Don't** — make M91 the only path for something that must not be missed.
  Keep your own durable record of the condition, so an unnoticed alert is
  recoverable rather than lost.

## Reach and escalation

### Only HIGH and CRITICAL place phone calls

- **True** — `LOW` and `MEDIUM` interrupt every phone at full volume through
  silent mode and Do Not Disturb, and stop there. Omitting `severity` gives
  `MEDIUM`.
- **`CRITICAL` dials at 45 seconds**, in pairs 90 seconds apart — two calls from
  one number inside three minutes is what passes iOS "Allow Repeated Calls" and
  Android's repeat-caller exception, so a silenced phone rings on the second.
- **`HIGH` dials at 3 minutes, and only into a dark channel** — nobody has
  responded *and* nobody has even opened the alert in the last few minutes. A
  channel where three people have it open is one where the push worked, and
  phoning it anyway is what used to make `HIGH` indistinguishable from
  `CRITICAL`. If your team is looking at the alert, no call is placed at all.
- **Why** — the default sits below the dialling threshold deliberately, so an
  alert sent by somebody who never thought about severity cannot ring real
  phones on their behalf.
- **Do** — choose severity explicitly for anything that matters, and be honest
  about which alerts genuinely justify a call.
- **Don't** — encode your own priority scheme into these four values. Here they
  mean exactly one thing: how hard M91 will try to reach somebody.

### Escalation is bounded, and the call carries no information

- **True** — calls go out in waves of 10, capped at 50 for one alert, at most
  one call per person every 5 minutes however many alerts are outstanding, with
  an hourly ceiling per channel. Escalation stops the moment somebody responds.
  The call only tells the person to open the app.
- **Why** — one person answering is the intended outcome and the wave size
  assumes it; calling everyone at once would be mostly wasted calls. The call
  text is fixed on the voice provider's side and cannot carry per-alert
  content.
- **Do** — put everything a person needs into `title` and `description`. By the
  time somebody has been called they are opening the app to find out what
  happened, and whatever is not written there is not available.
- **Don't** — design around "everyone will be called".

## The channel model

### You cannot alert an individual, only a channel

- **True** — there is no recipient field in the payload, and adding one is a
  `400`. The channel is the recipient list, resolved at send time from who has
  accepted membership.
- **Why** — it is the feature, not the gap: recipients change without a deploy,
  and nobody's phone number ends up in your source.
- **Do** — where you genuinely need one person, make a channel containing that
  person. Prefer several narrow channels over one wide one — a channel is the
  unit of who gets woken, so it is also the unit of consent.
- **Don't** — route unrelated alerts through one channel. That wakes people for
  things that were never theirs, and is how a team learns to ignore M91.

### Members must accept, and nothing you send can make them

- **True** — an invite is an offer. Somebody receives alerts only once they
  have installed M91, signed in with the invited number, *and* accepted. A
  channel where nobody accepted returns `409` on every send.
- **Why** — the link is perfect in that case and the alert reaches nobody, so
  there is no error that points at the real cause.
- **Do** — verify the roster before you rely on a channel, not on the night it
  matters. Compare `delivered` on a real send against the member count in the
  app, and build that into your setup process for a new integration.
- **Don't** — assume a created channel is a working one.

### A send link can only be used, never minted

- **True** — a send link cannot create a channel, invite somebody or mint
  another link; that is a person in the app. An
  [API key](https://m91-api.msg91.com/api/public/api.md#credentials) can invite people by
  phone number and create and staff team channels — and every invited person
  still has to accept on their own phone.
- **Why** — membership is consent, and consent is not something a caller with a
  token should be able to manufacture. An automated setup, including an AI
  assistant, can go no further than using a link it was given.
- **Do** — make the send link an input to your setup, exactly like a database
  URL: obtained once by a person, supplied as configuration.
- **Don't** — build tooling that expects to provision channels.

## The send link

### The link is a bearer credential sitting in a URL

- **True** — anyone who has the link can raise an alert on that channel. It is
  exposed by anything that logs a full request path, appears in shell and
  browser history, and is readable in any client-side code it ships in.
- **Why** — that exposure is the price of the thing that makes it useful: no
  headers and no code means any tool with a "URL to call" field can integrate.
- **Do** — treat it as a secret key with no second factor. Server-side only, in
  the project's existing secret store, never committed, and never log the
  request path for this route. Use a separate link per integration — they are
  free, and one noisy or compromised integration can then be cut off alone.
- **Don't** — add a default `title` to "fix" the fact that a bare link raises
  nothing. That behaviour is what makes a link preview safe to touch.

### Revoking is deletion, not rotation

- **True** — there is no rotation window and no overlap. Revoking deletes the
  link, and every integration still using it starts failing `401` immediately.
- **Why** — a link is the credential and the address at once; there is nothing
  to rotate *to* while keeping the old one alive.
- **Do** — know which systems use which link before you revoke one.
  Per-integration links make that answerable.
- **Don't** — share one link everywhere. A revocation then becomes an outage of
  your alerting.

## Rate limits and deduplication

### 60 alerts per minute per link, and repeats collapse

- **True** — past 60 a minute you get `429` with a `Retry-After`. Separately,
  byte-identical payloads from one link within 5 seconds collapse into one
  alert, and a `customId` whose alert is still open absorbs every repeat
  indefinitely, returning `200` with `delivered: 0`.
- **Why** — the 5-second guard exists to cover an honest timeout-and-retry, not
  to be a general retry mechanism.
- **Do** — put a `customId` on anything that can fire more than once and close
  it when the condition clears. That, not the rate limit, is the real mechanism
  for a repeating condition.
- **Don't** — retry in a tight loop without backoff. It exhausts the limit and
  takes your alerting offline exactly when the system is unhealthy.

### An open alert swallows every repeat of its customId

- **True** — an alert opened and never closed means that condition can never
  alert again. The sends keep returning `200`.
- **Why** — this is deduplication working as designed, which is why nobody
  notices it: there is no error, no failed send, and no metric that moves.
- **Do** — write the close at the same time as the send. Every `customId` you
  open needs a code path that closes it.
- **Don't** — use a `customId` at all if you cannot guarantee a close.

## Waiting on a human

### A decision reaches you by callback, or by polling if you prefer

- **True** — set `callbackUrl` on the send and M91 POSTs the outcome to your own
  server: `answered` the moment somebody responds, `closed` once when the alert
  ends. `GET <SEND_LINK>/alerts/<id>` still reports the decision for anything
  that would rather poll, and is not rate limited today, unlike the send
  endpoint's 60 a minute.
- **Why it matters** — polling works exactly as long as your process is alive to
  poll. An agent that raises an approval at 3am and dies at 3:15 never learns the
  answer, and the person who taps Approve at 07:40 has no way to know their
  decision reached nothing.
- **Do** — act on `answered`, and compare `label` against the option that means
  yes — after reading the alert back, because the callback secret behind
  `X-M91-Signature` is not shown anywhere yet, so the signature cannot be
  verified and the body alone must not be trusted.
  Deduplicate on `idempotencyKey` — delivery is at-least-once, so duplicates are
  certain rather than possible.
- **Don't** — wait for `closed` in order to act. With `isAutoClose` false, which
  is the default, an alert stays open after a SHARED response until somebody
  closes it, so an agent waiting for `closed` is waiting for a person.

### M91 still never expires an alert. You own the timeout

- **True** — there is no `expiresIn`. An alert stays open until somebody closes
  it, and a waiting caller waits indefinitely by default.
- **Why** — only the caller knows what "too long" means for its own workflow,
  and a platform default would be wrong for most of them. Closing on a timer
  would also contradict `isAutoClose` defaulting to false, which exists because
  closing is a statement that the thing is over, and only a person or the sender
  is in a position to make it.
- **Do** — set your own timeout, treat it as the safe outcome (for an approval,
  almost always "no"), and then **close the alert**: `POST <SEND_LINK>/close`
  with your `customId`. That is what stops it sitting in people's lists, and it
  is what produces the `closed` callback with everyone who answered and everyone
  who did not. Say in the `description` what happens if nobody answers.
- **Don't** — poll in a tight loop. It is unmetered today, which makes it a
  courtesy rather than a limit, and a limit is the obvious thing to add the
  first time somebody abuses it.

### The send link can read a decision but can never make one

- **True** — the link raises alerts, closes them, and reads their status. It
  cannot record a response.
- **Why** — the token lives in config files, CI logs and agent context, so it
  is assumed to leak. A leaked link that can only raise alerts is noise; one
  that could answer would let whoever holds it approve on somebody else's
  behalf.
- **Do** — if a decision can be made without a human, make it in code. It did
  not need an alert.
- **Don't** — build a flow that needs a machine to answer for a person. There
  is no API for it and there should not be.

## Responses

### An unreachable recipient can stop a PERSONAL-only alert from ever closing

- **True** — an alert whose options are all `PERSONAL` closes once nobody is
  still owed an answer. A member with no device registered keeps a recipient
  row in its initial state forever, so the count never reaches zero.
- **Why** — "owed an answer" is counted from recipient rows, and a person who
  was never sent anything cannot answer to clear theirs.
- **Do** — watch `noDevice` on sends to channels using `PERSONAL`-only options
  and get those people to open the app. Where an alert must settle regardless,
  include a `SHARED` option, or close it from your own code when the condition
  clears.
- **Don't** — rely on everyone answering.

### A response is final

- **True** — one response per person, and it cannot be changed or withdrawn. A
  `SHARED` response can never be superseded. Recipients choose among the
  options you offered, or — unless you set `other: false` — reply in their own
  words through M91's own "Something else".
- **Why** — a `SHARED` response means somebody has taken responsibility; from
  that moment the alert has an owner, and letting a later response overwrite
  that would make ownership unknowable.
- **Do** — design `responseOptions` as the complete set of answers, and keep it
  to two or three, read by somebody half-awake. Use them to find out who is
  taking the job.
- **Don't** — use them to collect information. Anything richer belongs in
  whatever system the person opens next, which you should name in
  `description`.

### An alert stays open until somebody closes it

- **True** — `isAutoClose` defaults to `false`. A `SHARED` response stands
  every other phone down but does not end the alert.
- **Why** — closing is a statement that the thing is over, and usually only a
  person is in a position to make it.
- **Do** — decide deliberately which alerts are *notices*: send those with an
  explicit `responseOptions: []` and `isAutoClose: true` so they close on
  delivery.
- **Don't** — leave the third case lying around. An alert with no response
  options and no `isAutoClose` has nothing that can ever settle it: it sits
  open forever and blocks its own `customId`.

## The people on the other end

### Severity is a promise to the people you wake

- **True** — every severity breaks through a silenced phone.
- **Why** — that is not a technical capability you are configuring, it is
  permission somebody gave you. It is revoked the moment it is abused — not by
  the API, but by people turning the app off or leaving the channel.
- **Do** — be able to answer, for every alert you add, what the person woken is
  supposed to *do*. Escalate severity for consequence.
- **Don't** — escalate out of your own anxiety about whether it will be
  noticed, and don't send an alert whose answer is "look at it in the morning".
  Reserving `HIGH` and `CRITICAL` is what keeps them meaning something at 3am.

### Alert volume is the thing that breaks alerting

- **True** — an alerting system does not usually fail by not delivering. It
  fails by delivering so much that people stop reacting.
- **Why** — there is no error code for that, no metric on the response, and
  nothing in this API that will warn you.
- **Do** — treat the number of alerts a channel raises per week as a number you
  watch and keep low. Review what a channel actually fired before adding
  anything new to it.
- **Don't** — reach for an integration change when a channel gets noisy. It is
  almost always a missing `customId`, a severity set too high, a condition that
  should have been a log line, or a check that flaps.
