# Post to Teams or Slack when a display goes offline

By the end, a small receiver posts a message to your team chat when a display goes offline and again when it is back. Nobody has to keep the fleet open to notice that the TV in the shop window has gone dark.

> **Type:** guide
> **For:** Developers who look after displays for a team that lives in Slack or Microsoft Teams
> **Prerequisite:** a package with webhooks (Premium and above), the Owner role for the endpoint, a key `ak_…` of the class **Read only** for the display names, and a server with a public https address
> **Where:** your own server and **Settings › For developers › Webhooks**

## How it works

Szenera can't post to a chat directly, because the chat expects its own message format and Szenera signs every delivery. A receiver of your own sits in between:

1. Szenera sends the event `fleet.player.status-changed` with `playerId`, `from`, and `to`.
2. The receiver checks the signature, ignores repeats, and reads the name of the display through the API.
3. It posts a message to the incoming webhook of your chat.

A display counts as offline when it hasn't checked in for five minutes. Before that, it is `stale` for a short time. The receiver reports only the step to `offline` and the return to `online`, so a short wobble doesn't flood the chat.

## Steps

1. **Create the incoming webhook in your chat.**
   - **Slack:** create an app with **Incoming Webhooks** for the channel. The address begins with `https://hooks.slack.com/`.
   - **Microsoft Teams:** in the channel, open Workflows and create a flow from a template that posts incoming webhook requests to the channel. The flow gives you an address that accepts Adaptive Cards.

2. **Save the receiver** on your server and start it with the four values:

   ```ts
   // offline-alert.ts: node offline-alert.ts
   import { createServer } from 'node:http';
   import { createHmac, timingSafeEqual } from 'node:crypto';

   const SECRET = process.env.WEBHOOK_SECRET ?? ''; // whsec_…
   const API = process.env.SZENERA_API;
   const KEY = process.env.SZENERA_API_KEY; // class Read only
   const CHAT = process.env.CHAT_WEBHOOK_URL ?? ''; // Slack or Teams
   const seen = new Set<string>();

   function isFromSignage(rawBody: string, header: string): boolean {
     const expected = createHmac('sha256', SECRET).update(rawBody).digest('base64url');
     const a = Buffer.from(expected);
     const b = Buffer.from(header);
     return a.length === b.length && timingSafeEqual(a, b);
   }

   async function displayName(playerId: string): Promise<string> {
     const res = await fetch(`${API}/v1/players/${playerId}`, {
       headers: { Authorization: `Bearer ${KEY}` },
     });
     return res.ok ? (await res.json()).player.name : playerId;
   }

   function chatMessage(text: string): unknown {
     if (CHAT.startsWith('https://hooks.slack.com/')) return { text };
     return {
       type: 'message',
       attachments: [
         {
           contentType: 'application/vnd.microsoft.card.adaptive',
           content: { type: 'AdaptiveCard', version: '1.4', body: [{ type: 'TextBlock', text, wrap: true }] },
         },
       ],
     };
   }

   createServer((req, res) => {
     const chunks: Buffer[] = [];
     req.on('data', (chunk) => chunks.push(chunk));
     req.on('end', async () => {
       const raw = Buffer.concat(chunks).toString('utf8');
       if (!isFromSignage(raw, String(req.headers['x-signature'] ?? ''))) {
         res.writeHead(401).end();
         return;
       }
       res.writeHead(204).end(); // Answer within 10 seconds, work afterwards

       const deliveryId = String(req.headers['x-delivery-id']);
       if (seen.has(deliveryId) || req.headers['x-event-type'] !== 'fleet.player.status-changed') return;
       seen.add(deliveryId);

       const { playerId, from, to } = JSON.parse(raw);
       let text = '';
       if (to === 'offline') text = `${await displayName(playerId)} is offline.`;
       if (to === 'online' && from === 'offline') text = `${await displayName(playerId)} is back online.`;
       if (!text) return;

       await fetch(CHAT, {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify(chatMessage(text)),
       });
     });
   }).listen(8080);
   ```

   ```bash
   export WEBHOOK_SECRET="whsec_…" CHAT_WEBHOOK_URL="https://hooks.slack.com/services/…"
   node offline-alert.ts
   ```

   Put the receiver behind your https address, for example `https://alerts.example.com/szenera`. The `seen` set lives in memory. If the receiver restarts, a retry can post a second message, which is harmless here. For anything that must happen only once, store the IDs.

3. **Create the endpoint** in the portal under **Settings › For developers › Webhooks** with **Create endpoint**. Enter the address under **Endpoint URL**, choose `fleet.player.status-changed` under **Subscribed events**, and click **Create**. Copy the secret from **Signing secret — visible only now** into `WEBHOOK_SECRET` and restart the receiver.

   The API route `POST /v1/webhooks` works too, but only with the access token of an Owner sign-in. An API key can't carry the webhook permissions, see [Create a webhook endpoint](/webhooks/create-endpoint).

4. **Try it.** Pull the network cable of a display, or close the browser tab of a web display. After about five minutes, the message arrives in the chat. Reconnect it, and the second message follows.

## Check the result

- In the **Delivery log** of the endpoint, every delivery shows **Delivered** with `HTTP 204`.
- The chat shows the name of the display, not its ID. If the ID appears, the key can't read the display.
- A delivery repeated with **Retry** in the delivery log doesn't post a second message.

## If something goes wrong

| Symptom | Meaning | Solution |
|---|---|---|
| The delivery log shows `HTTP 401` | The signature doesn't match: wrong secret, or a proxy changes the body. | Copy the secret again, and make sure nothing parses the body before the check. |
| The delivery log shows **Delivered**, but the chat stays empty | The chat rejects the message. | Log the response of the chat. Slack answers `invalid_payload` for a wrong format, and a Teams workflow shows the failed run in its history. |
| The chat shows the ID instead of the name | `GET /v1/players/{id}` fails: the key is missing, expired, or lacks `fleet.player.read`. | Use a key of the class **Read only**. |
| No delivery after five minutes | The endpoint isn't subscribed to the event, or the display was already offline. | Check **Subscribed events**, and see [When no delivery arrives](/webhooks/troubleshooting). |

## Related

- [Verify the signature of a delivery](/webhooks/verify-signature)
- [What is sent and how it is retried](/webhooks/delivery-and-retries)
- [Event types: content, programs, devices, apps](/webhooks/event-types-content-devices)
