The API
Everything in Tissue Mast is reachable over the API at api.tissue.systems/v1/mast/*: channels, vitals, probes, destinations, maintenance windows, the feed, acknowledgements. A script, a Cell or a CI job can make a channel, mute it for the length of a deploy, or read back what paged last night.
Sending an alert needs no token at all: a channel's URL is its own credential. Sending alerts has that. This page is everything else.
Credentials
| Credential | Where it lives | What it reaches |
|---|---|---|
| Channel key | In the send URL (mk_ + 40 hex) |
One channel, send only. No session, no header |
| Device | The app's keychain | Everything that owner has: channels, messages, devices, pairings |
| Account API token | Your account, mast:read / mast:write |
The same routes as the device, from anywhere |
A channel key still sends without any account at all. A token is for the rest: reading the feed, acknowledging from a script, creating a channel from a deploy pipeline.
Tokens
Make a token under API tokens in the web app, or with ribo. --scope is a repeatable flag, not a comma list:
ribo token create mast --scope mast:write --scope mast:readmast:read reads the feed, channels and state. mast:write makes and changes things. The same token can carry the platform scopes too.
A token reaches your account's channels as soon as it exists. A phone joins the account when someone signs in to Tissue Mast from Mast Pager, or enters a link code from Phones; the phone's channels are then the account's channels, and the token reaches those too.
Linking a phone by hand
For a phone that registered on its own and has never signed in, the account can claim it with the phone's device token. This is the call older versions of the app made after a token was pasted into them:
curl -X POST https://api.tissue.systems/v1/mast/connect \
-H "Authorization: Bearer $TISSUE_TOKEN" -H "Content-Type: application/json" \
-d '{"device_token": "mt_…"}'One account holds one owner and one owner holds one account. Claiming an owner that's already linked, or linking a second owner to an account, answers 409; the same request twice with the same pair answers 200 and changes nothing.
Channels from the command line
A channel is created with mast:write and answers with its first key. Take it here if a script needs it. A key that was never written down isn't lost, because /v1/mast/state carries the live key of every channel:
curl -X POST https://api.tissue.systems/v1/mast/channels \
-H "Authorization: Bearer $TISSUE_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "deploys", "default_priority": "normal", "quiet_start": 1320, "quiet_end": 420, "quiet_tz": "America/Los_Angeles"}'{
"id": "mc_2b7d4e6081a3c5f79024b6d8e0a2c4f6",
"name": "deploys",
"kind": "channel",
"url": "https://mast.tissue.dev/mk_1c9f…",
"key": "mk_1c9f…",
"default_priority": "normal",
"max_priority": "loud",
"quiet_start": 1320,
"quiet_end": 420,
"quiet_tz": "America/Los_Angeles",
"message_count": 0
}Names are 1–32 characters of letters, digits, ., _, - and single spaces, starting with a letter or digit. Letters means letters in any script (数据库 and café-prod are names), lowercase where the script has a case, and the count is characters rather than bytes. Quiet hours are minutes past midnight (1320 is 22:00, -1 is no quiet window) and a window that wraps midnight is written the way you would say it: start 1320, end 420.
max_priority is the channel's ceiling, and a channel created with a token starts at loud: every send is clamped to it, so this channel cannot page anyone until the ceiling is raised. Raising it to the pager tier is done in Mast Pager on a registered phone: the API takes a max_priority of page only from a device credential, on the create and on the PATCH alike, and answers 400 to a token either way. So a leaked channel key cannot promote its own alerts, and neither can the token that provisions the channel.
quiet_tz takes an IANA zone name such as America/Los_Angeles or Europe/Berlin, and resolves it at the moment it judges a window, so a window set once keeps meaning the same local hours after the clocks change. A fixed offset (-07:00, +0200, Z) is accepted too, and cannot do that: it is one number, right for half the year. Anything that resolves to neither is read as UTC, which the sending host records in its log.
Quiet hours and mute downgrade a send to a silent delivery rather than dropping it. The exception is a page, which is exempt from both. A page the ceiling clamped to loud is a loud in every respect, quiet hours included. The response says which happened:
state |
What it means |
|---|---|
queued |
Stored, and on its way to every device |
muted |
Stored and delivered silently: the channel is muted or inside its quiet window |
deduped |
Folded into a message already on the phone. The response also carries "duplicate": true |
suppressed |
Folded into a storm summary: the channel has already had storm_threshold messages inside storm_window_secs (20 in 300 seconds by default) |
alive |
A bare heartbeat on a vital. Proof of life, and no card, so id is null |
A suppressed message is stored and appears in the feed like any other; what collapses is the push, into one summary card per window that counts up in place. A page is exempt from storm control as well: it counts toward the window, and it is never the message that gets folded away.
Everything the owner has (devices, channels, pairings and the most recent messages) in one call:
curl -s https://api.tissue.systems/v1/mast/state \
-H "Authorization: Bearer $TISSUE_TOKEN" | jq '.channels[] | {name, kind, url, message_count}'Each channel here carries its live key, so a key that was never written down is readable again without rotating it.
The rest of the channel surface takes the same token:
# prove the whole path to the phone
curl -X POST https://api.tissue.systems/v1/mast/channels/mc_2b7d…/test \
-H "Authorization: Bearer $TISSUE_TOKEN"
# issue a new key; the old one keeps working for seven days
curl -X POST https://api.tissue.systems/v1/mast/channels/mc_2b7d…/rotate \
-H "Authorization: Bearer $TISSUE_TOKEN"
# silence a noisy channel until an instant, without touching the sender
curl -X POST https://api.tissue.systems/v1/mast/channels/mc_2b7d…/mute \
-H "Authorization: Bearer $TISSUE_TOKEN" -H "Content-Type: application/json" \
-d '{"until": "2026-08-20T15:00:00Z"}'Rotation is why a key belongs in a vault binding or a CI secret rather than in a source file: replacing one is an API call and a secret update, with a week of overlap to do it in.
To keep the whole set of channels and vitals in a file and let a script create, patch and rotate them, see Channels as code.
Vitals: page when something stops
A vital is a channel that expects to hear from you. Give it a period and a grace window; if no ping arrives inside period_secs + grace_secs, mast pages instead of waiting for a sender that is never going to call:
curl -X POST https://api.tissue.systems/v1/mast/channels \
-H "Authorization: Bearer $TISSUE_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "nightly-backup", "kind": "vital", "period_secs": 86400, "grace_secs": 3600, "vital_priority": "loud"}'The heartbeat is a send with no text, which is what lets a crontab line be one curl:
17 3 * * * /usr/local/bin/backup.sh && curl -fsS https://mast.tissue.dev/mk_8e2a… >/dev/nullA job that knows it failed says so, and flatlines the vital without waiting out the grace window:
curl -fsS -X POST https://mast.tissue.dev/mk_8e2a…/fail -d body="nightly backup exit 1"Either way, whether the grace window runs out or a /fail arrives, the silence opens a page, and the next heartbeat closes it. The red card on the phone turns green, stops repeating and stops escalating, and its text says how long the vital was flat. If one outage left several pages open, because a /fail sent its own key as the failure changed shape, the beat closes every one of them. A beat that arrives with no page open is a quiet card: there is nothing on the phone for it to answer.
From a Cell, the heartbeat belongs in the pulse handler. The schedule that does the work is the thing whose silence should page you:
[[pulse]]
schedule = "*/15 * * * *"
[[bindings]]
type = "vault"
binding = "VITAL_URL"export default {
async pulse(event, env) {
const rows = await sweep(env);
// The beat goes out only on the path that finished. A throw here reaches
// no `fetch`, the ping does not land, and the vital flatlines on its own.
await fetch(env.VITAL_URL, {
method: "POST",
body: new URLSearchParams({ body: `swept ${rows} rows` }),
});
},
};A vital accepts text like any other channel, and a send that carries text is proof of life too. Sending title/body on a beat is how the notification says what the job did rather than that it happened.
Deploy markers
A marker goes to the channel's URL with /deploy on the end, like a send. The Deploys page covers the fields and the wait gate. Reading them back takes a token:
| Route | Scope | Does |
|---|---|---|
POST /m/{key}/deploy |
the channel key | Record a deploy and check the channel's probes now. With wait, answer 200, 503 or 504 as they settle |
GET /v1/mast/deploys |
mast:read |
Your markers, newest first. channel_id for one channel, limit up to 50 |
Destinations and maintenance windows
Both have their own pages: Destinations for sending alert events to a webhook, Slack, Discord, Teams or email, and Maintenance windows for holding the sound while you work. The routes, for reference:
| Route | Scope | Does |
|---|---|---|
GET /v1/mast/destinations |
mast:read |
List destinations, with the plan's cap as limit |
POST /v1/mast/destinations |
mast:write |
Add one. A webhook's signing secret is in this answer and nowhere else |
GET /v1/mast/destinations/{id} |
mast:read |
Read one |
PATCH /v1/mast/destinations/{id} |
mast:write |
Change name or events, or pause and resume with state |
DELETE /v1/mast/destinations/{id} |
mast:write |
Remove it |
POST /v1/mast/destinations/{id}/test |
mast:write |
Send a test now and report the far end's answer |
GET /v1/mast/destinations/{id}/deliveries |
mast:read |
The delivery log, newest first |
POST /v1/mast/destinations/{id}/deliveries/{delivery}/replay |
mast:write |
Send a finished, given-up or skipped delivery again |
POST /v1/mast/destinations/{id}/reactivate |
mast:write |
Bring a dead destination back |
GET /v1/mast/maintenance |
mast:read |
List windows, hold windows included |
POST /v1/mast/maintenance |
mast:write |
Make a window |
PATCH /v1/mast/maintenance/{id} |
mast:write |
Change its name or scope, or its times before it starts |
POST /v1/mast/maintenance/{id}/end |
mast:write |
End an open window now and page what it held |
DELETE /v1/mast/maintenance/{id} |
mast:write |
Delete it, ending it first if it's open |
The feed
mast:read reads the same history the app shows, newest first:
curl -s "https://api.tissue.systems/v1/mast/messages?limit=20" \
-H "Authorization: Bearer $TISSUE_TOKEN" \
| jq -r '.messages[] | [.received_at, .priority, .title, .state] | @tsv'{
"messages": [
{
"id": "mm_7a3e5b91c04d2f68a1b3c5d7e9f02468",
"channel_id": "mc_2b7d4e6081a3c5f79024b6d8e0a2c4f6",
"received_at": "2026-08-19T03:17:44.902Z",
"priority": "loud",
"title": "db-01",
"body": "replication stopped",
"state": "acked"
}
],
"next_before": "2026-08-19T03:17:44.902Z~mm_7a3e5b91c04d2f68a1b3c5d7e9f02468"
}next_before is the cursor: pass it back as ?before= for the next page, and stop when it comes back null. ?limit= is 1–200 and defaults to 50; ?channel= (name or id) and ?priority= narrow the query. Messages are kept for the plan's history: 7 days on Free, 30 on Solo, 90 on Team, 365 on Company.
One message with its incident log. Every message keeps one, and the app shows it under the message: fired, delivered once per device, repeated with the count, escalated, acked with the device that did it, resolved, expired. It is the record to read when a page went off four times and you want to know why:
curl -s https://api.tissue.systems/v1/mast/messages/mm_7a3e… \
-H "Authorization: Bearer $TISSUE_TOKEN"A script that resolves what it raised needs mast:write. Acknowledging stops a repeating page; resolving ends the incident:
curl -X POST https://api.tissue.systems/v1/mast/messages/mm_7a3e…/resolve \
-H "Authorization: Bearer $TISSUE_TOKEN"A sender that holds only the channel key can still follow its own message, which is how a deploy script waits for a human before it continues:
id=$(curl -sS -X POST https://mast.tissue.dev/mk_1c9f… \
-d title="promote 2026.08.019?" -d priority=page | jq -r .id)
until [ "$(curl -sS https://mast.tissue.dev/mk_1c9f…/messages/$id | jq -r .state)" = "acked" ]; do
sleep 10
doneThe answer is the message's state and the facts behind it, and acked_by names the device that acknowledged it, as the app shows it:
{
"id": "mm_7a3e5b91c04d2f68a1b3c5d7e9f02468",
"state": "acked",
"received_at": "2026-08-19T03:17:44.902Z",
"dedupe_count": 0,
"acked_at": "2026-08-19T03:19:02.117Z",
"acked_by": "iPhone",
"resolved_at": null,
"expires_at": "2026-08-19T04:17:44.902Z"
}That key reads its own channel and nothing else: a message id from another channel answers 404, exactly as an id that never existed does.