on this page

IntegrationsDestinations

Destinations

A destination is somewhere Tissue Mast tells about an alert besides your phone: a webhook of your own, a Slack, Discord or Teams channel, or the inbox of someone on the account. You save it once and pick the events it hears about. From then on every alert on the account opens, escalates, gets acknowledged and closes there too.

A destination is a record of what happened. It never takes the place of the page: the phone still rings whether a destination answers or not.

For one alert that should report back to one URL, there's callback= on the send and nothing to save.


Kinds

kind target Plan
webhook An https:// URL of your own. Every request is signed Solo and up
slack A Slack incoming-webhook URL on hooks.slack.com Every plan with an account
discord A Discord channel webhook URL on discord.com or discordapp.com Every plan with an account
teams A Teams workflow URL, on *.logic.azure.com, *.powerplatform.com or *.environment.api.powerplatform.com Every plan with an account
email The email address of a member of this account Every plan with an account
telegram Coming soon

Slack gets a short Block Kit message, Discord an embed from "Tissue Mast", Teams an Adaptive Card, and email a plain subject with a text and an HTML part. Each one carries a headline such as Acknowledged by Denis: db is down, the channel, the first 600 characters of the body, and a link to the incident at tissue.systems/mast/app/incidents/<id>.

A webhook gets the whole event as JSON, described under Webhooks.

Email goes only to people on the account. An address that isn't a member is refused with a 400, and membership is checked again at every send, so someone who leaves the account stops getting mail and their destination goes dead.


Events

Event type in a webhook When
opened mast.message.opened A send opened a card
escalated mast.message.escalated Nobody answered and the page went to the next person
acked mast.message.acked Someone acknowledged it
resolved mast.message.resolved It was closed, by a person, a resolve send or a recovery
expired mast.message.expired It ran out of time with nobody answering
probe_down mast.probe.down A probe paged
probe_recovered mast.probe.recovered That probe came back
vital_flatline mast.vital.flatlined A vital stopped beating

Leave events out and the destination hears all eight. Name some and it hears only those. An unknown name is a 400 that lists the real ones, and an empty list is a 400 too.

An incident opens with probe_down when a probe raised it, vital_flatline when a vital did, and opened for everything else. A destination that subscribes to the opening event always gets it before anything later about the same alert, even when the destination was added halfway through the incident, so an acked never arrives for something the receiver was never told about. Events for one alert arrive in order at each destination.


Adding one

curl -X POST https://api.tissue.systems/v1/mast/destinations \
  -H "Authorization: Bearer $TISSUE_TOKEN" -H "Content-Type: application/json" \
  -d '{"kind": "slack", "name": "#ops", "target": "https://hooks.slack.com/services/T0C4B8XKD/B07RL2M9Q/Vh3kPz…", "events": ["opened", "acked", "resolved"]}'
{
  "destination": {
    "id": "md_4f1a9c2e7b30d58a6e1f4c7b92d03a5e",
    "kind": "slack",
    "name": "#ops",
    "target": "https://hooks.slack.com/...",
    "events": ["opened", "acked", "resolved"],
    "state": "active",
    "signed": false,
    "last_ok_at": "",
    "last_error": "",
    "created_at": "2026-10-06T17:02:11.408Z"
  }
}

The answer is 201. name is up to 80 characters and defaults to the kind. The target is stored encrypted and never comes back whole: a URL shows as its host, an address as d***@example.com.

Creating a destination needs mast:write. These are the refusals:

Answer Why
400 An unknown kind or event, a name over 80 characters, a target the kind doesn't allow, or an email that isn't a member
402 plan_limit The plan's destination cap is reached, the owner has no Tissue Mast account, or a webhook on Free
503 outbound_disabled Destinations aren't switched on for this server

Changing, pausing and removing

# rename it, or change what it hears
curl -X PATCH https://api.tissue.systems/v1/mast/destinations/md_4f1a… \
  -H "Authorization: Bearer $TISSUE_TOKEN" -H "Content-Type: application/json" \
  -d '{"events": ["opened", "resolved"]}'

# stop sending to it for a while
curl -X PATCH https://api.tissue.systems/v1/mast/destinations/md_4f1a… \
  -H "Authorization: Bearer $TISSUE_TOKEN" -H "Content-Type: application/json" \
  -d '{"state": "paused"}'

# remove it
curl -X DELETE https://api.tissue.systems/v1/mast/destinations/md_4f1a… \
  -H "Authorization: Bearer $TISSUE_TOKEN"

A PATCH takes name, events and state. state is paused (from active or failing) or active (from paused). A paused destination gets nothing new, and what was already queued for it is skipped rather than held for later. The target can't be changed: delete the destination and add it again. Deleting one drops whatever was still queued for it.

GET /v1/mast/destinations lists them all with the plan's cap as limit (null where there isn't one), and GET /v1/mast/destinations/{id} reads one.

Testing one

curl -X POST https://api.tissue.systems/v1/mast/destinations/md_4f1a…/test \
  -H "Authorization: Bearer $TISSUE_TOKEN"
{ "ok": false, "status": 404, "error": "HTTP 404" }

The test sends straight away and answers 200 with what the far end said. ok: false is the far end refusing, not the API failing, and status is null when no HTTP answer came back at all. A webhook's test event has type mast.test.


When a destination doesn't answer

Any 2xx counts as delivered. Anything else is tried again, on this schedule after the first attempt:

Retry 1 2 3 4 5 6 7 8
After 5 s 30 s 2 min 30 min 2 h 6 h 12 h 24 h

That's nine attempts over about 44 and a half hours. A 429 with a Retry-After of a day or less waits that long instead. Each attempt gets 5 seconds to connect and 10 seconds in all. Redirects are not followed, so a 3xx counts as a failure and is retried.

While attempts fail, the destination shows failing with the last error. The next success puts it back to active. After the ninth failure that one delivery is given up, and the destination stays failing for the next event.

Some answers mean the URL is gone for good. A 404 or 410 from Slack, Discord or Teams means the webhook was deleted on their side, and the destination goes dead at once with nothing retried. A 404 from your own webhook is retried like anything else, since a deploy can briefly lose a route.

state What it means
active Delivering
failing The last attempt failed and retries are running. last_error says why
paused You paused it
dead The far end said the URL is gone, or an email's owner left the account

A dead destination comes back with POST /v1/mast/destinations/{id}/reactivate. Fix the far end first. Anything else answers 409 not_dead.

The delivery log

curl -s "https://api.tissue.systems/v1/mast/destinations/md_4f1a…/deliveries?limit=20" \
  -H "Authorization: Bearer $TISSUE_TOKEN"

Each delivery has its event, the message it was about, its state, how many attempts it took, the last status, when the next attempt is due, and a log of up to the 20 most recent attempts with their time, status and error. limit defaults to 50 and goes up to 200.

A delivery that finished, gave up or was skipped can be sent again:

curl -X POST https://api.tissue.systems/v1/mast/destinations/md_4f1a…/deliveries/mob_9c2e…/replay \
  -H "Authorization: Bearer $TISSUE_TOKEN"

The replay answers 202 and sends the same body the first attempt did, with the same webhook-id and a fresh run of retries. One still waiting on a retry answers 404.


Webhooks

A webhook destination gets a POST with a JSON body for each event:

{
  "type": "mast.message.acked",
  "version": 1,
  "event_id": "evt_3b8e1f0a6c4d92e7",
  "occurred_at": "2026-10-06T03:19:02.117Z",
  "message": {
    "id": "mm_7a3e5b91c04d2f68a1b3c5d7e9f02468",
    "channel_id": "mc_2b7d4e6081a3c5f79024b6d8e0a2c4f6",
    "channel_name": "db",
    "title": "db-01",
    "body": "replication stopped",
    "url": "",
    "priority": "page",
    "state": "acked",
    "dedupe_key": "db-01-replication",
    "dedupe_count": 2,
    "received_at": "2026-10-06T03:17:44.902Z",
    "acked_at": "2026-10-06T03:19:02.117Z",
    "acked_by": "iPhone",
    "resolved_at": "",
    "escalated_at": "",
    "repeat_count": 1
  }
}

The body is written once, when the event happens, so a retry hours later still says what was true at the time. A field with nothing in it is an empty string, not null. event_id is the same at every destination that hears the event.

The request carries these headers:

Header Value
content-type application/json
user-agent tissue-mast-webhook/1
webhook-id The delivery's id (mob_…). It stays the same across retries and replays, so use it to drop a duplicate
webhook-timestamp Seconds since the epoch, when this attempt was signed
webhook-signature v1, and the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}

Signing follows Standard Webhooks, so any of its libraries verifies a request. The secret comes back once, as secret, in the answer that created the destination:

{ "destination": { "id": "md_8d20…", "kind": "webhook", "signed": true, … }, "secret": "whsec_q8Vb2kL0…" }

Keep it then: it isn't shown again, and there's no call to read it back or rotate it. To change it, add a new destination and delete the old one.

Verifying by hand in Node, if you'd rather not take a library:

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret, headers, rawBody) {
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const signed = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${rawBody}`;
  const expected = createHmac("sha256", key).update(signed).digest();
  const age = Math.abs(Date.now() / 1000 - Number(headers["webhook-timestamp"]));
  return age < 300 && headers["webhook-signature"].split(" ").some((sig) => {
    const got = Buffer.from(sig.replace(/^v1,/, ""), "base64");
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}

Verify against the raw body as it arrived. Parsing and re-serialising the JSON changes the bytes.

What a webhook URL can be

The target must be https:// on port 443, with a host name rather than an IP address and no user name or password in it. Every address the name resolves to has to be a public one: a name that resolves to a private, loopback, link-local, carrier-grade NAT or documentation range, even for one of its addresses, is refused when you add it and again on every attempt. The request goes to the address that was checked, so a name can't be swapped to an internal address between the check and the call.


During maintenance

A maintenance window holds the sound on your phone, and it holds the people-facing destinations with it. While a window covers an alert, Slack, Discord, Teams and email hear nothing about it. When the window closes, every alert it held that's still open gets its opening event sent to those destinations, followed by anything after, whether or not the close pages your phone again for it.

An alert that opened and was acknowledged or closed inside the window never reaches them at all.

The hold covers alerts that arrive while the window is open and would have made a sound. An alert that was already open when the window began, and a send that was quiet or silent to start with, or that quiet hours or a mute had already quieted, reach these destinations as usual.

Webhooks and callback= URLs aren't held. They get every event as it happens, window or not.