# What goes in an alert

Every field, what it is for, and **when a request needs it**. If you are choosing
what to send, this is the page. For the exact shapes, limits and errors, see
[the API contract](https://m91-api.msg91.com/api/public/api.md).

## [What any alert is made of](#pieces)

Six pieces. Take what the situation needs and leave the rest out — but read the
"needs it when" column before leaving something out, because two of these are
left out far too often.

| Piece | The request needs it when… | Field |
|---|---|---|
| **The line** | always | `title` |
| **The detail** | the person has to decide something, and the title alone cannot carry it | `description` |
| **The urgency** | always worth setting — the default never phones anybody | `severity` |
| **The answers** | a person is expected to DO something, which is almost every alert | `responseOptions` |
| **The evidence** | there is a graph, screenshot or document that makes the decision obvious | `attachments` |
| **The way back** | your software is waiting on the answer | `callbackUrl` |

Two more decide how the alert ends: `isAutoClose` and `customId`. They are
covered under [How an alert ends](#ends) and [Repeating conditions](#repeats),
because neither makes sense on its own.

## [Worked examples](#examples)

Find your situation here rather than inventing a shape.

| You want | Send | The woken person sees |
|---|---|---|
| Somebody to take an outage on | one `SHARED` + one or two `PERSONAL` | **I'll handle it** · **Seen** |
| A decision — approve a refund, gate a deploy | two `SHARED` | **Approve** · **Reject** |
| A reason when the answer is no | two `SHARED`, the negative one with `required: ["note"]` | **Approve** · **Reject** (asks why) |
| Proof the work was done | `SHARED` with `required: ["attachment"]` | **Fixed it** (asks for a photo) |
| Everyone to confirm individually | only `PERSONAL` | one button each; closes when the last person answers |
| Nothing — just interrupt them | `"responseOptions": []` **and** `"isAutoClose": true` | no buttons |

```json
{
  "title": "Payments API returning 500s",
  "description": "Error rate 40% since 02:10. Checkout is failing.",
  "severity": "CRITICAL",
  "attachments": ["https://status.example.com/graphs/error-rate.png"],
  "callbackUrl": "https://your-app.example.com/hooks/m91",
  "responseOptions": [
    { "label": "I'll handle it", "effect": "SHARED" },
    { "label": "Already fixed",  "effect": "SHARED", "required": ["note"] },
    { "label": "Seen",           "effect": "PERSONAL" }
  ]
}
```

## [One option is not a choice](#one-option)

**If there is only one button to tap, you have sent a notification with extra
steps.** Give two or more, or give none and set `isAutoClose: true`.

This is the single most common mistake, and it is made by people and agents who
understand `SHARED` and `PERSONAL` perfectly well. A lone "Acknowledge" tells you
somebody saw it and nothing else: nobody has claimed it, nobody is released, and
the alert still needs closing by hand.

Ask what the *different* answers are. If the honest answer is "there is only
one", you wanted a notification — send the empty array.

## [Omitted is not empty](#omitted)

Leaving `responseOptions` out and sending `[]` are **different**, and the
difference is the worst alert M91 can produce.

| You send | What happens |
|---|---|
| `"responseOptions": []` **+** `"isAutoClose": true` | rings, then closes itself. A clean notification. |
| `"responseOptions": []`, no `isAutoClose` | rings, and **nothing can ever close it** |
| no `responseOptions` at all | rings, **nobody can answer and nothing can close it** |

An alert nobody can settle stays at the top of everybody's list forever. If you
want a pure notification, send the empty array **explicitly** and
`isAutoClose: true`.

---

## [`title`](#title)

**What.** A short heading that sums up the situation at a glance. 5–2000
characters. The only required field.

**Why.** It is read in the half-second before somebody decides whether to get out
of bed, often from a lock screen, often in a second language. Everything else in
the alert is read after that decision.

**Needs it when.** Always.

**How.** Name the thing and what is wrong with it: `"Payments API returning
500s"`. Not `"Alert"`, not `"Error in service"`, not a stack trace's first line.

**Also:** `title` is what makes a `GET` link raise an alert. A bare
`GET /s/<token>` with no `title` is a connection test and reaches nobody — that
is a security control, because the token is in a URL and crawlers and link
previews fetch it without anybody deciding to.

## [`description`](#description)

**What.** The fuller explanation. Up to 2000 characters.

**Why.** The title says what broke. This says what the person needs in order to
act — the numbers, the since-when, the thing they would otherwise have to go and
look up at 3am.

**Needs it when.** The person has to make a decision. Skip it for an alert whose
title is the whole story.

**How.** What happened, how bad, since when, and anything needed to act. Not a
stack trace: put that behind an `attachments` link.

## [`severity`](#severity)

**What.** How hard M91 chases an answer. `LOW` `MEDIUM` `HIGH` `CRITICAL`.
Defaults to `MEDIUM`.

**Why.** Every alert is worth interrupting somebody for, or it should not be an
alert. What differs is what happens when the first ring is ignored.

**Needs it when.** Always worth setting deliberately — the default never phones
anybody, which is right for a default and wrong for a real outage.

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

**How to choose.** By what happens if nobody answers.

- `LOW` — on their phone, not chased. A deploy finished.
- `MEDIUM` — somebody should look, soon. A queue backing up.
- `HIGH` — needs a person now, but phoning is only right if the team has gone
  quiet. If somebody opens the alert, no call is placed at all.
- `CRITICAL` — money or safety is draining. Calls come 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 mistake 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 real `CRITICAL` reaches nobody.

**Calls keep coming until somebody responds.** After the first wave, anybody
still outstanding who has not opened the alert is called again — every 10 minutes
on `CRITICAL`, for as long as the chain runs. Answering the phone is not
responding: M91 never learns whether a call was picked up, so somebody who
answers and then does nothing is still an unanswered alert.

**A response stops all of it immediately.** Opening the alert pauses that
person's re-rings for three minutes and takes them out of the call list, so
somebody already working on it is not shouted at.

## [`responseOptions`](#responses)

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

**Why.** An alert that only says "something is wrong" makes every recipient ring
each other to find out who is dealing with it. The options turn the alert itself
into the coordination: one person taps, and everybody else's phone stops.

**Needs it when.** A person is expected to do something — which is almost every
alert. See [One option is not a choice](#one-option) and
[Omitted is not empty](#omitted) before deciding you do not need them.

**How.** Each option is a label plus an effect, and may ask for a note or a file.

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

`SHARED` — this person is taking responsibility. The alert is removed from
everyone else in the channel and nobody else can answer.

`PERSONAL` — this person is not taking responsibility. The alert remains for
everyone else.

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

**Up to 20 by POST, 6 by link.** Labels must differ.

### Asking for a note or a file

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

**Why.** "Fixed it" with no explanation is the answer that produces the next
three questions. The moment of answering is the only time the person still has
the context — the photo of the tripped breaker, the sentence saying what they
actually did. And when a callback feeds an agent, it needs a value, not a button
press.

**Never put a requirement on every option.** Every one you add is time between a
ringing phone and the alert being handled. M91 does not enforce this: if every
option is `required` and `other` is `false`, nobody can answer at all.

### `other` — replies in the person's own words

Defaults to `true`. M91 adds its own **"Something else"**, which lets somebody
type instead of picking. It is always `PERSONAL`, 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.

## [`attachments`](#attachments)

**What.** `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.** The person you woke has to decide in seconds whether this needs them. A
title says "error rate is up". A graph shows *how far* up — and often turns a
page-and-investigate into a look-and-know.

**Needs it when.** There is a graph, a screenshot or a document that makes the
decision obvious. Not for a stack trace somebody has to read.

**How.** Links only — M91 shows your link and does not keep a copy, so **the
link has to stay alive** for the alert to keep showing the file.

**The link must open without a login.** This is the trap: a link to your internal
Grafana works in your browser because you are signed in, and reaches nothing from
somebody else's phone. Signed URLs work well here. A link that fails is skipped
silently and **the alert still goes out** — losing an alert because a picture was
unavailable would be the wrong trade.

## [`callbackUrl`](#callback)

**What.** An `https` endpoint M91 POSTs to when somebody answers, and again when
the alert ends.

**Why.** Without it the only way to learn what a human decided is to poll
`GET /alerts/<id>` until it changes. Every integration was writing the same loop
at its own interval, so systems either reacted slowly or hammered us.

**Needs it when.** A machine is waiting on the answer — an agent that asked for
approval, a deploy paused on a human gate, a refund that executes only on
"Approve". If only a person will read the reply, leave it out.

**How.** Two events, and you get both. `answered` fires **once per person**, on
every response. `closed` fires once, when the alert ends.

```json
{
  "event": "answered",
  "at": "2026-10-01T12:14:09.535Z",
  "alert": { "id": "…", "customId": "deploy-4821", "title": "Deploy 4821 needs approval" },
  "response": {
    "label": "Approve", "effect": "SHARED",
    "by": { "id": "…", "name": "Aryan" },
    "at": "2026-10-01T12:13:58.000Z",
    "note": "checked the migration, go ahead",
    "attachments": []
  },
  "idempotencyKey": "<alertId>-<userId>-<generation>"
}
```

`closed` carries the final picture instead of one response:

```json
{
  "event": "closed",
  "reason": "closed",
  "at": "2026-10-01T12:14:09.535Z",
  "alert": { "id": "…", "customId": "deploy-4821", "title": "…" },
  "responses": [ … ],
  "pending": [ { "id": "…", "name": "Priya" } ],
  "idempotencyKey": "<alertId>-closed-<generation>"
}
```

`reason` is `responded` when a response auto-closed it, `closed` when somebody
closed it by hand or through `POST /s/<token>/close`.

**De-duplicate on `idempotencyKey`, not on the alert id.** Delivery is
at-least-once and retried, so duplicates are certain rather than possible — and
one alert produces many `answered` events, so keying on the alert would drop all
but the first.

`note` and `attachments` are **always present** — `null` and `[]` when not given
— 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, and be idempotent** — M91 retries, so the same `answered`
may arrive twice. Key on `alertId` plus the responder.

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

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

| What happens | Does it close? |
|---|---|
| A `SHARED` response | only if you sent `isAutoClose: true` |
| Everyone gave 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` absent | **no — nothing can settle it** |

**There is no server-side expiry and no `expiresIn`.** An agent that waits
forever waits forever. Decide what a non-answer means before you send, treat the
timeout as the safe outcome (usually "no"), and close the alert yourself with
`POST /s/<token>/close`.

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

## [Repeating conditions](#repeats)

`customId` names the **condition**, not the run — up to 200 characters. 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.

**Closing is what re-arms it.** Leave it open and every later failure is absorbed
in silence.

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

Independently of this, byte-identical payloads from one sender inside a 5-second
window collapse into one alert, so a retry after a timeout does not raise a
second one.

## [How these fields interact](#interactions)

Each field above is simple on its own. These three combinations are where real
integrations go wrong, and none of them is visible from a single field's
description.

| Combination | What you get |
|---|---|
| no `responseOptions` + `isAutoClose` absent | **an alert nobody can answer and nothing can close** |
| every option `required` + `other: false` | **nobody can answer at all** — M91 does not refuse this |
| `severity: MEDIUM` + nobody looking | re-rings for 2 minutes, then silence. **No call, ever** |
| `severity: HIGH` + somebody opened the alert in the last 3 minutes | **no call is placed** — opening it is what makes the channel not-dark |
| `SHARED` option + `isAutoClose: false` | one person claims it, everyone else is released, **and it stays open** until closed |
| `callbackUrl` + `PERSONAL`-only options on 300 people | **300 `answered` callbacks.** Use `closed` if you only want the final picture |
