Sending alerts
Every channel is a URL, and a send is one HTTP request to it. There's no client library and no SDK: anything that can make a request can page you, from a shell, a Cell, a CI job, Grafana or a router's webhook field.
This page has every way to send, every field a send takes, callbacks, and the sounds. Recipes has a problem and the send that fixes it, one at a time. Home Assistant and Opsgenie have their own pages.
With a channel key
The key is the whole credential. Anything that can make an HTTP request can page you, and the shapes below are all the same send:
# form-encoded, the shape most tools reach for first
curl -X POST https://mast.tissue.dev/mk_1c9f… \
-d title="db-01" -d body="replication stopped" -d priority=loud
# JSON
curl -X POST https://mast.tissue.dev/mk_1c9f… \
-H "Content-Type: application/json" \
-d '{"title": "db-01", "body": "replication stopped", "priority": "loud"}'
# a bare line of text — the body is the message
curl -X POST https://mast.tissue.dev/mk_1c9f… -d "replication stopped"
# GET, for a sender that cannot issue anything else
curl -G https://mast.tissue.dev/mk_1c9f… --data-urlencode body="replication stopped"{ "ok": true, "id": "mm_7a3e5b91c04d2f68a1b3c5d7e9f02468", "state": "queued" }A send answers 202 once the message is stored, before the push goes out. Delivery is what the feed and the message's own state record; the 202 is not a claim that a phone made a noise. The same id is in the X-Mast-Message-Id response header, for a caller that would rather not parse the body. A repeat that folded into an earlier message answers with that message's id and "duplicate": true.
From a Cell
There is no mast binding, and type = "mast" is refused at deploy with a pointer to this shape. A Cell sends the way anything else does: with the channel URL, kept out of the source in a vault binding:
[[bindings]]
type = "vault"
binding = "MAST_URL" # env.MAST_URL in your cellribo vault set my-cell MAST_URL # paste the channel URL, then deployexport default {
async fetch(request, env, ctx) {
try {
return await handle(request, env);
} catch (err) {
// A page is fire-and-forget: the request has already failed, and holding
// the response open for a notification only makes the failure slower.
ctx.waitUntil(page(env, err));
return new Response("internal error", { status: 500 });
}
},
};
function page(env, err) {
return fetch(env.MAST_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: "checkout",
body: String(err?.message ?? err).slice(0, 4096),
priority: "page",
url: "https://tissue.systems/dashboard",
url_title: "Open the dashboard",
key: "checkout-500", // one incident, however many requests hit it
}),
});
}key is the dedupe key: repeats inside the channel's dedupe window fold into the message that is already on the phone instead of arriving again. A failing checkout that throws four hundred times pages once.
With an account token
POST /v1/mast/send takes the same fields as a channel URL, plus a channel naming which one. It is the send for code that already holds an account credential and should not also hold a key:
curl -X POST https://api.tissue.systems/v1/mast/send \
-H "Authorization: Bearer $TISSUE_TOKEN" -H "Content-Type: application/json" \
-d '{"channel": "deploys", "title": "2026.08.019", "body": "live on both edges"}'channel accepts a name or a channel id. A name that is not yours answers 404, the same as a name that does not exist.
From CI
GitHub Actions, with the channel URL in a repository secret. A key that can only send to one channel is the right credential for a job that only needs to say one thing:
- name: Page on a failed release
if: failure()
run: |
curl -fsS -X POST "${{ secrets.MAST_URL }}" \
-d title="release ${{ github.ref_name }}" \
-d body="${{ github.workflow }} failed on ${{ github.sha }}" \
-d priority=loud \
-d url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
-d url_title="Open the run"Send fields
| Field | Meaning |
|---|---|
title |
Up to 250 characters. Optional if body is present |
body |
Up to 4096 characters. Required when title is absent, except on a resolve |
priority |
quiet, normal, loud or page. Defaults to the channel's |
url, url_title |
A tap target on the notification. http:// or https:// only |
key |
Dedupe key, up to 120 characters. Repeats inside the window fold into one message |
resolve |
1 to close the open message carrying this key, or a message id to close that one. See Incidents |
ack |
required to make any priority wait for a human. A page always does |
retry |
Seconds between repeats of an unacknowledged message: 30–86,400, or 0 to disable |
expire |
Seconds after which an unanswered message stops repeating: 60–604,800, or 0 to disable |
sound |
One of the names in Sounds. Overrides the channel's sound |
silent |
1 stores the message and sends no push. Not with ack or a page |
timestamp |
Accepted and range-checked. The feed orders on arrival, not on a sender's clock |
callback |
An https:// URL told when this message opens, is acknowledged and is resolved. Up to 512 characters. Solo and up. See Callbacks |
hold |
Seconds to hold this channel's pages without a sound, starting with this one: 1–21,600, or 0 for none. Not with silent. See Maintenance windows |
| Priority | What it does |
|---|---|
quiet |
Lands in the notification list without a sound or a lit screen |
normal |
An ordinary notification, delivered immediately |
loud |
Time-sensitive: asks to break through Focus and Notification Summary. The person can switch that off |
page |
Repeats until acknowledged, and ignores the channel's mute and quiet hours. On the phone it breaks through Focus when Mast is allowed time-sensitive notifications, and sounds through the ringer switch only where critical alerts are turned on |
loud and page both break through a Focus, Sleep included. What separates them is the ringer switch and the repeat, not whether the phone stays quiet at 03:00, so normal is the tier for something that can wait until morning and loud is a decision to wake somebody. What softens a loud overnight is the channel's quiet hours, which are unset until you set them; a page ignores them by design.
A channel carries a ceiling as well as a default, and a send is clamped to it: ask for page on a channel whose ceiling is loud and the message is stored and delivered as loud. The 202 doesn't say so (it carries ok, id and state), but the stored message does, so reading the message back returns the priority it was delivered at, not the one that was asked for. The ceiling is max_priority, it is set in the app on a registered phone, and no API token can raise it. Channels and levels has why.
Callbacks
Put callback= on a send and Tissue Mast calls that URL back as the message moves: when it opens, when someone acknowledges it, and when it's resolved. It's for a sender that wants to know what happened to its own alert, such as a ticket that closes itself when the page is acknowledged, without saving a destination that hears about everything.
curl -X POST https://mast.tissue.dev/mk_1c9f… \
-d title="db-01" -d body="replication stopped" -d priority=page \
-d callback="https://ops.example.com/hooks/mast"The callback gets a POST with the same JSON body a webhook destination gets, with type set to mast.message.opened (or mast.vital.flatlined when the send is a vital's failure), mast.message.acked or mast.message.resolved. The body format is under Webhooks. The headers are the same too, webhook-id and webhook-timestamp included, except that a callback isn't signed: there's no webhook-signature, because there's no secret to sign with. Treat the URL itself as the secret, or check the message id in the body against one you sent. For signed requests, save a webhook destination instead.
Failed calls are retried on the same schedule as a destination's, nine attempts over about 44 and a half hours. A callback is never held by a maintenance window: it hears every event as it happens.
The URL has to be https:// on port 443, with a host name rather than an IP address and no user name or password, and it can't resolve to a private or internal address. A callback that breaks those rules doesn't cost you the page. The send still goes through, the callback is dropped, and the 202 says so:
{
"ok": true,
"id": "mm_7a3e5b91c04d2f68a1b3c5d7e9f02468",
"state": "queued",
"warning": "callback ignored: it must be an https URL on port 443 with a host name, not an IP address, and no credentials"
}Callbacks are on Solo and up. On a lower plan the send goes through, the callback is dropped, and the warning reads callback ignored: callback URLs are part of the Solo plan and up. A URL over 512 characters is different: that's a 400 and nothing is stored.
The callback belongs to the message the send opened. A repeat that folds into a card already on the phone doesn't add a second callback or replace the first.
Sounds
The app carries six sounds. Name one with sound= on a send, or set it on the channel so
every message there uses it.
| Name | Length | What it is |
|---|---|---|
pager |
20s | Four sharp beeps, a pause, again. An old pocket pager, and what a page plays when nothing else is set |
klaxon |
20s | Two low tones alternating without a gap. A machine room |
rising |
18s | Starts slow and low, ends fast and high, so how long it has been ringing is audible from the next room |
chirp |
15s | A rising two-note pair every second and a half. Long, but not the loudest thing in a house |
bleep |
0.7s | Two notes down, twice. The short one |
default |
~1s | The phone's own notification tone |
curl -s -F priority=page -F sound=klaxon \
-F title='db-01' -F body='replication stopped' \
https://mast.tissue.dev/<key>.caf on the end is accepted and optional: sound=pager and sound=pager.caf are the same
thing. Any other name is refused with a 400 naming the list, because a phone answers a sound it
cannot find by playing the default and reporting nothing: an unchecked typo would be a page that
silently loses the alarm it asked for.
Length only matters where the phone is allowed to make noise. quiet carries no sound at all,
and normal and loud stop at the first silent switch or Focus that catches them. A page on a
phone with critical alerts turned on is the one tier that plays to the end at full volume, which
is why it is the tier with a twenty-second default.
Sounds ship with the app, so a phone running a version from before they did plays its own notification tone instead. Nothing breaks; the page just sounds like every other notification.
Limits
| Body | 16 KiB, per request |
| Send rate | 60 a minute per key, 600 a minute per owner |
| Messages a month | Your plan's: 10,000 on Free, 1,000 on a phone with no account |
| History | Your plan's: 7 days on Free and with no account, 30 on Solo, 90 on Team, 365 on Company |
| Rotated key | Keeps working for 7 days |
A refused field is named in the response. 429 means one of the two rate limits, not that anything was stored. Plans and limits has the rest.