on this page

IntegrationsThe API

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:read

mast: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/null

A 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
done

The 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.