on this page

MastMast & Your Account

Mast and your Tissue account

Mast is a push-notification app for iPhone and Apple Watch, on the App Store as Mast Pager. A script posts a line of text to a channel URL and the phone that owns the channel gets a notification — quietly, or as a card that repeats until someone acknowledges it.

Mast has its own identity. The first device that registers creates an owner, and every channel, key and message belongs to that owner. None of that requires a Tissue account, and the app is complete without one. If you want an account for the rest of this page, create one here.

Connecting an account adds one thing, and it is worth a page because of what it opens up: an API token from your account can then do what the phone can. Every /v1/mast/* route takes either the device's own credential or a Tissue token carrying mast:read / mast:write, so channels, sends, history and acknowledgements are reachable from a shell, a Cell, or a CI job.


What connecting changes

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
Tissue API token Your account, mast:read / mast:write The same routes as the device, from anywhere — after the account is connected

Nothing about the app changes. Channels you already have keep their keys, the phone keeps using its own credential, and a channel key still sends without any account at all. What connecting removes is the need to mint and distribute a key for work that is already authorised by your account: reading the feed, acknowledging from a script, creating a channel from a deploy pipeline.


Connecting

Create a token first. --scope is a repeatable flag, not a comma list:

ribo token create mast --scope mast:write --scope mast:read

mast:write is the scope POST /v1/mast/connect checks; mast:read is what the feed endpoints want. In the app, open Tissue, paste the token, and tap Connect.

The call the app makes, if you would rather do it by hand — the account token is the bearer, and the device token identifies the owner being claimed:

curl -X POST https://api.tissue.systems/v1/mast/connect \
  -H "Authorization: Bearer $TISSUE_TOKEN" -H "Content-Type: application/json" \
  -d '{"device_token": "mt_…"}'
{
  "id": "mo_4f8c1d20a7b3e6591c4d8f0a2b6e3c75",
  "account_id": "acct_dd0d1cd52dec596f",
  "linked_at": "2026-08-19T14:22:05.118Z",
  "plan": "trial",
  "retention_days": 30
}

One account holds one mast owner and one owner holds one account. Claiming an owner that is already linked, or linking a second owner to an account, answers 409; sending the same request twice with the same pair answers 200 and changes nothing. There is no unlink route — disconnecting in the app forgets the token on that phone, and the account stays attached to the owner.


Sending

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 leaves for Apple. Delivery is what the feed and the message's own state record; the 202 is not a claim that a phone made a noise.

From a Cell

There is no mast binding. 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 cell
ribo vault set my-cell MAST_URL     # paste the channel URL, then deploy
export 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"

Channels from the command line

A channel is created with mast:write and answers with its first key. The key is shown once, in this response:

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": "-07:00"}'
{
  "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": "-07:00",
  "message_count": 0
}

Names are 1–32 characters of lowercase letters, digits, ., _, - and single spaces, starting with a letter or digit. 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 the Mast app 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 is a fixed UTC offset-07:00, +0200, Z — not an IANA zone name. Resolving America/Los_Angeles needs a time-zone database this service does not carry, and a wrong guess would silence a page at the wrong hour, so an offset it cannot parse is read as UTC rather than approximated. A zone that observes DST therefore needs its offset updated twice a year, or a window wide enough not to care.

Quiet hours and mute downgrade a send to a silent delivery rather than dropping it — except 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="pg_dump exit 1"

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.


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 30 days.

One message with its incident log — every delivery, ack and escalation that happened to it:

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

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.


Send fields

Field Meaning
title Up to 250 characters. Optional if body is present
body Up to 4096 characters. Required when title is absent
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
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 Overrides the channel's sound
timestamp Accepted and range-checked. The feed orders on arrival, not on a sender's clock
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

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 does not 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 — see Channels from the command line.


Making a page wake you

A page is delivered time-sensitive. That breaks through a Focus, Sleep included, but it does not beat the ringer switch: only a critical alert does, and Mast sends one only to a phone that has critical alerts turned on for it. Three settings decide whether a page actually wakes anybody, and all three belong to whoever is holding the phone.

Setting Where
Allow Mast in the Focus you sleep under Settings → Focus → Sleep → Apps
Leave Time Sensitive on for Mast Settings → Notifications → Mast
Leave the ringer on The switch on the side of the phone

The second is not the first under another name. Each Focus carries its own Time Sensitive Notifications toggle, which admits every app already allowed under that Focus; the per-app switch lives under Notifications, and a page is judged against both.

The app says where it stands rather than assuming. The line under the page tier reads time sensitive · breaks through Focus once the first two are set, and turns amber with a button that opens the right Settings pane when a page could go unheard. A page sent to a test channel confirms the rest.


Limits

Body 16 KiB, per request
Send rate 60/minute per key, 600/minute per owner
Messages 10,000 per owner per calendar month, as fair use
History 30 days
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.