# Samy – Dokumentation > Samy ist eine Handy-App (iOS und Android), die Nachrichten aus selbst gebauter Software als Push aufs Handy bringt. Ein Tool schickt mit einem HTTP-Aufruf eine Nachricht an Samy. Nachrichten können Knöpfe haben, deren Tipp per Webhook an das Tool zurückgemeldet wird. API-Adresse: https://api.packlets.app. Die Verbindung zu einem Tool heißt „Packlet“ und hat einen eigenen Schlüssel. --- ## Überblick Was Samy macht, wann du einen Kanal und wann eine App nimmst und wie diese Doku aufgebaut ist. Samy bringt Software, die du selbst gebaut hast, aufs Handy. Dein Tool schickt mit einem einzigen HTTP-Aufruf eine Nachricht, Samy zeigt sie als Push. Und was du in der App tippst, schaltest oder schreibst, kommt bei deinem Tool wieder an. Dein Code läuft dabei weiter bei dir: auf dem Rechner, einem Server, einem Raspberry Pi oder als Cronjob. Samy ist nur der Weg aufs Handy und zurück. ### Kanal oder App Jedes Tool, das du verbindest, bekommt in der App einen eigenen Platz. Beim Verbinden wählst du, was dein Tool tun soll: **Mir Bescheid geben** (ein Kanal) oder **Eigene App bauen** (eine App). In einem Kanal kannst du außerdem **Antworten erlauben**, dann schreibst du mit deinem Tool wie in einem Chat. Diesen Schalter kannst du jederzeit umlegen. | In der App | Nimm es, wenn … | Beispiele | |---|---|---| | **Mir Bescheid geben** (Kanal) | dein Tool von sich aus etwas meldet. | Server fällt aus, Deploy oder Backup schlägt fehl, neue Bestellung, Tagesbericht | | … mit **Antworten erlauben** | du deinem Tool etwas sagen oder es etwas fragen willst. | eigener KI-Agent, Bot mit Befehlen | | **Eigene App bauen** (App) | du immer wieder etwas nachsehen oder abarbeiten willst. | Rechnungen freigeben, Server-Dashboard, Pull Requests prüfen, Support-Postfach | Unsicher? Fang mit einem Kanal an. Er braucht nur einen HTTP-Aufruf. Eine App ist eine Webseite, die du selbst baust und auf deinem Server betreibst. Kann dein Tool schon an Slack, Discord oder ntfy schicken, geht es ganz ohne Code: Tipp beim Verbinden auf **Integrationen** und wähle [Slack](https://packlets.app/docs/slack.md), [Discord](https://packlets.app/docs/discord.md) oder [ntfy](https://packlets.app/docs/ntfy.md). So meldet sich zum Beispiel dein Uptime-Monitor in Samy. ### So ist die Doku aufgebaut Lies von oben nach unten. Jeder Teil baut nur auf den Teilen davor auf. | Teil | Was du dort findest | |---|---| | **Loslegen** | [Schnellstart](https://packlets.app/docs/schnellstart.md): die erste Nachricht in fünf Minuten. Danach [So funktioniert Samy](https://packlets.app/docs/konzepte.md) mit allen Begriffen. | | **Kanal** | [Nachrichten senden](https://packlets.app/docs/senden.md), [Knöpfe](https://packlets.app/docs/knoepfe.md) und [Elemente](https://packlets.app/docs/nachrichten-elemente.md) darin, [Vorlagen](https://packlets.app/docs/vorlagen.md), [Push und Ruhezeit](https://packlets.app/docs/push.md). | | **Auf Eingaben reagieren** | Der [Webhook](https://packlets.app/docs/webhook.md): So erfährt dein Tool, was du in der App tust. Knöpfe, Elemente und Antworten bauen darauf auf. | | **Antworten** | Einen [Chat-Bot bauen](https://packlets.app/docs/chat.md). | | **Apps** | [Eigene App bauen](https://packlets.app/docs/apps.md): deine Webseite als App in Samy. | | **Referenz** | Zum Nachschlagen: jeder [Aufruf](https://packlets.app/docs/api.md), jedes [Element](https://packlets.app/docs/elemente.md), jedes [Ereignis](https://packlets.app/docs/ereignisse.md), jeder [Fehler](https://packlets.app/docs/fehler.md). | | **Hilfe** | [Fehlersuche](https://packlets.app/docs/fehlersuche.md), [fertiger Code](https://packlets.app/docs/beispiele.md) und [Sicherheit](https://packlets.app/docs/sicherheit.md). | Du lässt lieber deinen Coding-Assistenten bauen? Dann reicht [Mit KI bauen](https://packlets.app/docs/ki.md). ### Kosten Die App, Push-Nachrichten, Knöpfe, Chats und eigene Apps sind kostenlos. --- ## Schnellstart In fünf Minuten zur ersten Nachricht auf dem Handy. Am Ende dieser Seite hat dein Handy eine Push-Nachricht bekommen, die du selbst geschickt hast. Du brauchst die Samy-App und ein Terminal. ### 1. Packlet anlegen Ein **Packlet** ist die Verbindung zwischen einem deiner Tools und Samy. Leg in der App eins an: 1. Tippe oben links auf das Plus: **Packlet verbinden**. 2. Wähle **Mir Bescheid geben** und tippe auf **Weiter**. 3. Gib einen Namen ein, zum Beispiel „Test“, und such dir einen Avatar aus. 4. Tippe auf **Packlet anlegen**. Das Feld für die Webhook-URL bleibt leer. Die brauchst du erst, wenn dein Tool auf Knöpfe reagieren soll. ### 2. Schlüssel ablegen Samy zeigt jetzt den **Schlüssel** deines Packlets. Er beginnt mit `samy_` und ist das Passwort, mit dem dein Tool Nachrichten schicken darf. > **Achtung:** Den Schlüssel siehst du nur dieses eine Mal. Kopier ihn jetzt. Geht er verloren, erzeugst du in den Einstellungen des Packlets einen neuen. Leg ihn im Terminal als Umgebungsvariable ab. So steht er nicht im Code: ```bash export SAMY_KEY="samy_…" ``` ### 3. Erste Nachricht schicken Die App wartet jetzt auf die erste Nachricht. Schick sie: ```bash curl https://api.packlets.app/v1/push \ -H "Authorization: Bearer $SAMY_KEY" \ -H "Content-Type: application/json" \ -d '{"title":"Hallo","text":"Mein Tool ist verbunden"}' ``` ```python import os, requests requests.post("https://api.packlets.app/v1/push", timeout=10, headers={"Authorization": f"Bearer {os.environ['SAMY_KEY']}"}, json={"title": "Hallo", "text": "Mein Tool ist verbunden"}).raise_for_status() ``` ```typescript const res = await fetch("https://api.packlets.app/v1/push", { method: "POST", headers: { Authorization: `Bearer ${process.env.SAMY_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ title: "Hallo", text: "Mein Tool ist verbunden" }), }); if (!res.ok) throw new Error(`Samy ${res.status}: ${await res.text()}`); ``` Als Antwort kommt die Kennung der Nachricht: ```json { "message_id": "5b0c1a52-7c1e-4a43-9d0b-2f6f0c7d1e11" } ``` ### 4. Fertig Die App zeigt **Verbunden!**, und die Nachricht steht in deinem neuen Kanal. Ist die App im Hintergrund, kommt sie als Push. Dieselben drei Zeilen kannst du jetzt in jedes Skript einbauen: ans Ende eines Backups, in einen Cronjob, in einen Fehler-Handler. ### Wenn es nicht klappt | Was du siehst | Ursache | |---|---| | `401` mit `invalid_key` | Der Schlüssel stimmt nicht. Prüf mit `echo $SAMY_KEY`, ob er vollständig ist. | | `400` mit `invalid_body` | Im JSON fehlt `text`, oder es ist leer. | | `200`, aber kein Push | Die Nachricht ist da, nur der Push fehlt: Öffne den Kanal und prüf, ob Mitteilungen für Samy erlaubt sind. | Mehr dazu unter [Fehlersuche](https://packlets.app/docs/fehlersuche.md). ### Wie es weitergeht - Verstehen, was du gerade gebaut hast: [So funktioniert Samy](https://packlets.app/docs/konzepte.md). - Mehr aus der Nachricht machen: [Nachricht senden](https://packlets.app/docs/senden.md) und [Knöpfe](https://packlets.app/docs/knoepfe.md). - Den Aufruf von einer KI einbauen lassen: [Mit KI bauen](https://packlets.app/docs/ki.md). - Fertige Funktionen für Python und TypeScript: [Fertiger Code](https://packlets.app/docs/beispiele.md). --- ## So funktioniert Samy Die Begriffe und das Zusammenspiel: dein Tool, Packlet, Schlüssel, Aufrufe hin und Webhook zurück. Diese Seite erklärt die Begriffe, die in der ganzen Doku vorkommen, und wie die Teile zusammenspielen. Code gibt es hier keinen. ### Die Begriffe | Begriff | Bedeutung | |---|---| | **Dein Tool** | Dein Code: ein Skript, ein Server, ein Cronjob, ein KI-Agent. Er läuft bei dir, nicht bei Samy. | | **Packlet** | Die Verbindung zwischen einem deiner Tools und Samy. Du legst es in der App an. Es hat einen Namen, einen Avatar, eine Art und einen Schlüssel. | | **Kanal** | So erscheint ein Packlet in der App: als Eintrag im Tab **Kanäle** oder als Kachel im Tab **Apps**. | | **Kanal-Art** | Kanal („Mir Bescheid geben“) oder App („Eigene App bauen“). Du wählst sie beim Verbinden. Danach steht sie fest. | | **Antworten erlauben** | Schalter für Kanäle: Du schreibst deinem Tool, es antwortet. Lässt sich jederzeit umlegen. | | **Schlüssel** | Das Passwort deines Packlets, beginnt mit `samy_`. Mit ihm meldet sich dein Tool bei Samy an. | | **Webhook-URL** | Die Adresse deines Tools, an die Samy meldet, was du in der App tust. | Faustregel: Ein Tool, ein Packlet. So hat jedes seinen eigenen Kanal, und du kannst einen Schlüssel tauschen, ohne dass die anderen Tools ausfallen. ### Zwei Richtungen **Hin: Dein Tool ruft Samy.** Dein Tool schickt einen HTTP-Aufruf mit dem Schlüssel. Samy speichert, zeigt es im Kanal und schickt einen Push. Dafür braucht dein Tool nur ausgehendes Internet. Es muss nicht erreichbar sein. **Zurück: Samy ruft dein Tool.** Tippst du einen Knopf, legst einen Schalter in einer Nachricht um oder schreibst in einem Kanal mit Antworten, schickt Samy ein Ereignis an die Webhook-URL deines Packlets. Dafür muss dein Tool unter einer öffentlichen `https`-Adresse erreichbar sein. Die Richtung zurück ist freiwillig. Ein Kanal, der nur meldet, braucht sie nicht. Knöpfe, Elemente und Antworten leben davon. Eine App geht einen dritten Weg: Sie ist deine eigene Webseite. Was du dort tippst, schickt die Seite direkt an deinen Server. Samy sagt ihm über ein [Session-Token](https://packlets.app/docs/api-seiten.md#session-token), wer die Seite öffnet. > **Hinweis:** Ohne Webhook-URL speichert Samy deine Eingaben und zeigt sie in der App, aber dein Tool erfährt nichts davon. Es wird auch nichts nachgeliefert, wenn du die Adresse später einträgst. ### Was jede Art kann | | Kanal | Kanal mit Antworten | App | |---|---|---|---| | Wo in der App | Tab Kanäle | Tab Kanäle | Tab Apps, als Kachel | | Dein Tool schickt Nachrichten (`POST /v1/push`) | ja | ja, als Antworten | ja, sie kommen nur als Push | | Du schreibst deinem Tool | – | ja | – | | Dein Tool zeigt eine eigene Webseite (`PUT /v1/ui`) | – | – | ja | | Knöpfe und Elemente in Nachrichten | ja | ja | – | | Braucht einen Webhook | nur für Knöpfe und Elemente | ja | nein, die Seite spricht mit deinem Server | Eine App zeigt keine Nachrichten, nur ihre Seite. Schickt sie trotzdem eine Nachricht, kommt die als Push, und die Kachel zeigt einen Zähler. ### Knopf oder Button Beides lässt dich dein Tool vom Handy aus bedienen. Der Unterschied: | | Knopf | Button | |---|---|---| | Wo | unter einer Nachricht, Feld `buttons` | Element in einer Nachricht, Feld `elements` | | Wie oft | einmal. Danach zeigt die Nachricht, was du gewählt hast. | beliebig oft | | Gut für | eine Antwort auf eine Frage: „Freigeben“, „Ablehnen“ | eine Aktion, die du wiederholst: „Neu starten“, „Cache leeren“ | | Ereignis am Webhook | `button` | `ui` | Der Button ist eines von sechs **Elementen**. Die anderen sind Text, Schalter, Eingabefeld, Diagramm und Tabelle. Du hängst sie an eine [Nachricht](https://packlets.app/docs/nachrichten-elemente.md). ### Was Samy speichert - **Nachrichten** bleiben im Kanal, bis du sie löschst. Langes Drücken auf eine Nachricht löscht sie. Dein Tool erfährt davon nichts. - **Werte** von Schaltern und Eingabefeldern in Nachrichten speichert Samy selbst. Dein Tool muss sie nicht zurückschicken. - **Der Schlüssel** wird nie im Klartext gespeichert. Mehr dazu unter [Schlüssel und Sicherheit](https://packlets.app/docs/sicherheit.md). - **Das Protokoll** in den Einstellungen jedes Packlets zeigt die letzten Aufrufe deines Tools und die Webhook-Aufrufe an dein Tool. ### Wie es weitergeht - Mit dem Feed anfangen: [Nachricht senden](https://packlets.app/docs/senden.md). - Die Richtung zurück einrichten: [Webhook einrichten](https://packlets.app/docs/webhook.md). --- ## Mit KI bauen Die Doku als reiner Text, damit dein Coding-Assistent die Anbindung für dich schreibt. Die ganze Dokumentation gibt es als reinen Text. So kann ein Coding-Assistent wie Claude Code oder Cursor sie lesen und die Anbindung für dich schreiben. ### 1. Packlet anlegen Das Packlet legst du selbst in der App an, wie im [Schnellstart](https://packlets.app/docs/schnellstart.md) beschrieben. Den Schlüssel legst du als Umgebungsvariable `SAMY_KEY` ab. Gib ihn nicht in den Chat mit der KI. Die App zeigt beim Verbinden unter **Claude Code** auch einen fertigen Auftrag, passend zur gewählten Kanal-Art. ### 2. Der KI die Doku geben | Adresse | Inhalt | |---|---| | [/llms-full.txt](https://packlets.app/llms-full.txt) | Die ganze Dokumentation in einer Datei. Im Zweifel diese. | | [/llms.txt](https://packlets.app/llms.txt) | Kurzes Inhaltsverzeichnis mit Links auf alle Seiten als Markdown. | | `/docs/.md` | Jede einzelne Seite als Markdown, zum Beispiel [/docs/senden.md](https://packlets.app/docs/senden.md). | Auf jeder Seite der Doku steht oben außerdem der Knopf **Seite für KI kopieren**. ### 3. Auftrag geben Kopier diesen Auftrag und ersetz den Teil in eckigen Klammern: ```text Lies zuerst die Samy-Dokumentation unter https://packlets.app/llms-full.txt. Baue dann in dieses Projekt eine Anbindung an Samy ein: - Eine Funktion, die eine Nachricht über POST https://api.packlets.app/v1/push schickt. - Der Schlüssel kommt aus der Umgebungsvariable SAMY_KEY und steht nie im Code. - Fehler (400, 401, 409, 429) werden sichtbar gemeldet und nicht verschluckt. - [Beschreibe hier, wann eine Nachricht kommen soll und welche Knöpfe sie hat. Bei einer App: welche Seite sie zeigen soll, zum Beispiel offene Rechnungen zum Freigeben.] Wenn Knöpfe ohne url, Elemente oder ein Chat vorkommen: Baue zusätzlich einen Webhook-Endpunkt, der die Signatur im Header X-Samy-Signature prüft und auf type (button, ui, message) reagiert. Bei einer App: Baue eine eigene Webseite mit samy.css und samy.js, setze sie einmal mit PUT https://api.packlets.app/v1/ui und {"page": "https://…"}, und prüfe auf deinem Server bei jeder Anfrage das Session-Token aus samy.fetch(). ``` ### 4. Prüfen - Lass die KI eine Testnachricht schicken und sieh in der App nach. - Kommt nichts an, zeigt das Protokoll in den Einstellungen des Packlets die letzten Aufrufe. Mehr dazu unter [Fehlersuche](https://packlets.app/docs/fehlersuche.md). - Sieh nach, dass der Schlüssel nicht im Code oder im Repository gelandet ist. --- ## Nachricht senden Der eine Aufruf, mit dem dein Tool eine Nachricht schickt: Text, Titel, Formatierung, Link und Bild. Eine Nachricht ist ein einziger Aufruf: `POST /v1/push`. Diese Seite zeigt, was du hineinpacken kannst. Im Kanal ist es eine Meldung oder (mit Antworten) die Antwort deines Tools. Auch eine App kann so eine Nachricht schicken, sie kommt dann als Push. **Du brauchst:** ein Packlet und seinen Schlüssel in `SAMY_KEY`, siehe [Schnellstart](https://packlets.app/docs/schnellstart.md). ### Text und Titel Pflicht ist nur `text`. Mit `title` bekommt die Nachricht eine Überschrift. ```bash curl https://api.packlets.app/v1/push \ -H "Authorization: Bearer $SAMY_KEY" \ -H "Content-Type: application/json" \ -d '{"title": "Backup", "text": "Backup von heute Nacht ist fertig."}' ``` ```python import os, requests res = requests.post("https://api.packlets.app/v1/push", timeout=10, headers={"Authorization": f"Bearer {os.environ['SAMY_KEY']}"}, json={"title": "Backup", "text": "Backup von heute Nacht ist fertig."}) res.raise_for_status() print(res.json()["message_id"]) ``` ```typescript const res = await fetch("https://api.packlets.app/v1/push", { method: "POST", headers: { Authorization: `Bearer ${process.env.SAMY_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ title: "Backup", text: "Backup von heute Nacht ist fertig." }), }); if (!res.ok) throw new Error(`Samy ${res.status}: ${await res.text()}`); const { message_id } = (await res.json()) as { message_id: string }; ``` - `text` darf bis zu 10.000 Zeichen lang sein. Lange Texte klappt die App ein und zeigt „Weiterlesen“. - `title` darf bis zu 80 Zeichen lang sein. Ohne Titel steht im Push nur der Name deines Packlets. In welchem Kanal die Nachricht landet, bestimmt der Schlüssel. Eine Kanal-Angabe gibt es nicht. ### Formatierung Im `text` ist einfache Formatierung erlaubt. Die App stellt sie dar, im Push auf dem Sperrbildschirm werden die Zeichen entfernt. | Schreibweise | Ergebnis | |---|---| | `## Überschrift` | Überschrift | | `- Punkt` oder `• Punkt` | Liste | | `1. Punkt` | nummerierte Liste | | `**fett**` | Fettdruck | | Leerzeile | neuer Absatz | Anderes Markdown wie Links, Kursiv, Tabellen oder Code wird nicht umgewandelt und erscheint so, wie du es schreibst. In JSON schreibst du einen Zeilenumbruch als `\n`, eine Leerzeile also als `\n\n`: ```json { "title": "Tagesbericht", "text": "## Heute\n- 3 Deploys, alle **erfolgreich**\n- Backup um 3:00 fertig\n\n## Offen\n1. Zertifikat für shop.example.de läuft in 9 Tagen ab\n2. 2 Bestellungen noch nicht versendet" } ``` ### Link und Bild | Feld | Was die App zeigt | |---|---| | `url` | Der Link der Nachricht. Ohne Bild zeigt die App eine Zeile mit der Adresse; tippen öffnet sie. | | `image` | Das Bild über dem Text. Zusammen mit `url` öffnet ein Tipp auf das Bild den Link. | ```json { "title": "Neuer Treffer: Rennrad, 450 €", "text": "Gerade eingestellt, 3 km entfernt.", "url": "https://example.com/anzeige/1042", "image": "https://example.com/anzeige/1042/bild.jpg" } ``` Beides müssen öffentliche `https`-Adressen sein. Samy lädt das Bild nicht hoch, das Handy holt es direkt von dieser Adresse. Zeigt `url` auf ein Video (YouTube, Vimeo oder eine Videodatei), liegt auf dem Bild ein Play-Symbol. ### Die Antwort ```json { "message_id": "5b0c1a52-7c1e-4a43-9d0b-2f6f0c7d1e11" } ``` Status `200` heißt: Die Nachricht ist gespeichert und steht im Kanal. Die `message_id` brauchst du nur, wenn die Nachricht [Knöpfe](https://packlets.app/docs/knoepfe.md) hat. Dann kommt sie im Webhook wieder. Jeder andere Status ist ein Fehler, und die Nachricht wurde nicht gespeichert. Die Ursache steht im Feld `error`, siehe [Fehlercodes](https://packlets.app/docs/fehler.md). ### Wie es weitergeht - Auf eine Nachricht antworten können: [Knöpfe](https://packlets.app/docs/knoepfe.md). - Schalter, Diagramme oder Tabellen in die Nachricht setzen: [Elemente in Nachrichten](https://packlets.app/docs/nachrichten-elemente.md). - Steuern, ob und wie laut der Push kommt: [Push, Ton und Ruhezeit](https://packlets.app/docs/push.md). - Alle Felder auf einen Blick: [POST /v1/push](https://packlets.app/docs/api-push.md). --- ## Knöpfe Mit Knöpfen unter einer Nachricht öffnest du Links oder antwortest deinem Tool mit einem Tipp. Unter einer Nachricht können bis zu drei Knöpfe stehen. Ein Knopf öffnet entweder einen Link, oder er meldet deinem Tool, dass du ihn getippt hast. **Du brauchst:** [Nachricht senden](https://packlets.app/docs/senden.md). Für Aktions-Knöpfe zusätzlich einen [Webhook](https://packlets.app/docs/webhook.md). ### Knöpfe mitschicken Knöpfe stehen im Feld `buttons`. Jeder braucht eine `id` und ein `label`: ```bash curl https://api.packlets.app/v1/push \ -H "Authorization: Bearer $SAMY_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Neue Rechnung: Hosting GmbH", "text": "**189,00 €**, fällig am 20.11. Freigeben?", "buttons": [ {"id": "approve", "label": "Freigeben"}, {"id": "pdf", "label": "PDF öffnen", "url": "https://example.com/rechnungen/2026-118.pdf"} ] }' ``` ```python import os, requests requests.post("https://api.packlets.app/v1/push", timeout=10, headers={"Authorization": f"Bearer {os.environ['SAMY_KEY']}"}, json={"title": "Neue Rechnung: Hosting GmbH", "text": "**189,00 €**, fällig am 20.11. Freigeben?", "buttons": [ {"id": "approve", "label": "Freigeben"}, {"id": "pdf", "label": "PDF öffnen", "url": "https://example.com/rechnungen/2026-118.pdf"}, ]}).raise_for_status() ``` ```typescript await fetch("https://api.packlets.app/v1/push", { method: "POST", headers: { Authorization: `Bearer ${process.env.SAMY_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ title: "Neue Rechnung: Hosting GmbH", text: "**189,00 €**, fällig am 20.11. Freigeben?", buttons: [ { id: "approve", label: "Freigeben" }, { id: "pdf", label: "PDF öffnen", url: "https://example.com/rechnungen/2026-118.pdf" }, ], }), }); ``` | Feld | Bedeutung | |---|---| | `id` | Deine Kennung für den Knopf, bis 64 Zeichen, pro Nachricht eindeutig. | | `label` | Die Beschriftung, bis 40 Zeichen. | | `url` | Macht den Knopf zum Link-Knopf. | ### Zwei Arten Ob ein Knopf einen Link öffnet oder eine Aktion auslöst, entscheidet allein das Feld `url`. | | Link-Knopf | Aktions-Knopf | |---|---|---| | Erkennbar an | hat `url` | hat keine `url` | | Beim Tippen | Die Adresse öffnet sich auf dem Handy. | Samy meldet den Tipp an deinen Webhook. | | Dein Tool erfährt es | nein | ja | | Wie oft | beliebig oft | einmal pro Nachricht | Im Beispiel oben ist „Freigeben“ ein Aktions-Knopf und „PDF öffnen“ ein Link-Knopf. ### Was nach dem Tipp passiert Tippst du einen Aktions-Knopf, passieren zwei Dinge: 1. Die Nachricht zeigt sofort, welchen Knopf du gewählt hast. Die Aktions-Knöpfe dieser Nachricht sind damit erledigt, ein zweiter Tipp geht nicht. 2. Samy schickt das Ereignis `button` an deinen Webhook: ```json { "type": "button", "packlet_id": "9d2f7c10-3a5b-4c8e-9f21-6b7d8e9f0a12", "message_id": "5b0c1a52-7c1e-4a43-9d0b-2f6f0c7d1e11", "button_id": "approve", "pressed_at": "2026-11-09T18:00:00.000Z" } ``` `button_id` ist die `id`, die du vergeben hast. `message_id` ist der Wert, den du beim Senden als Antwort bekommen hast. Merk ihn dir, wenn dein Tool wissen muss, zu welcher Meldung der Tipp gehört. Wie dein Tool das Ereignis empfängt, steht unter [Webhook einrichten](https://packlets.app/docs/webhook.md). > **Hinweis:** Hat dein Packlet keine Webhook-URL, zeigt die App den Tipp trotzdem an. Dein Tool erfährt aber nichts davon. ### Wann lieber Elemente Knöpfe sind für eine einmalige Antwort. Brauchst du einen Schalter, ein Eingabefeld oder einen Button, den man mehrmals drücken kann, nimm [Elemente in Nachrichten](https://packlets.app/docs/nachrichten-elemente.md). --- ## Elemente in Nachrichten Schalter, Eingabefelder, Diagramme und Tabellen direkt in einer Nachricht. Eine Nachricht kann mehr als Text und Knöpfe enthalten: einen Button, einen Schalter, ein Eingabefeld, ein Diagramm, eine Tabelle. Diese Elemente gibt es nur in Nachrichten. **Du brauchst:** [Nachricht senden](https://packlets.app/docs/senden.md). Für bedienbare Elemente zusätzlich einen [Webhook](https://packlets.app/docs/webhook.md). ### Elemente mitschicken Elemente stehen im Feld `elements`, bis zu 12 pro Nachricht. Jedes hat einen `type` und eine `id`, die in der Nachricht eindeutig ist: ```bash curl https://api.packlets.app/v1/push \ -H "Authorization: Bearer $SAMY_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Fehlerrate nach Deploy", "text": "Seit Deploy v2.14 schlagen 6 % der Anfragen fehl.", "elements": [ {"type": "button", "id": "rollback", "label": "Auf v2.13 zurückrollen"}, {"type": "toggle", "id": "auto_deploy", "label": "Automatische Deploys", "value": true}, {"type": "chart", "id": "fehler", "label": "Fehlerrate", "unit": "%", "chart": "line", "points": [{"label": "9:00", "value": 0.2}, {"label": "10:00", "value": 6.2}]} ] }' ``` ```python import os, requests requests.post("https://api.packlets.app/v1/push", timeout=10, headers={"Authorization": f"Bearer {os.environ['SAMY_KEY']}"}, json={"title": "Fehlerrate nach Deploy", "text": "Seit Deploy v2.14 schlagen 6 % der Anfragen fehl.", "elements": [ {"type": "button", "id": "rollback", "label": "Auf v2.13 zurückrollen"}, {"type": "toggle", "id": "auto_deploy", "label": "Automatische Deploys", "value": True}, {"type": "chart", "id": "fehler", "label": "Fehlerrate", "unit": "%", "chart": "line", "points": [{"label": "9:00", "value": 0.2}, {"label": "10:00", "value": 6.2}]}, ]}).raise_for_status() ``` ```typescript await fetch("https://api.packlets.app/v1/push", { method: "POST", headers: { Authorization: `Bearer ${process.env.SAMY_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ title: "Fehlerrate nach Deploy", text: "Seit Deploy v2.14 schlagen 6 % der Anfragen fehl.", elements: [ { type: "button", id: "rollback", label: "Auf v2.13 zurückrollen" }, { type: "toggle", id: "auto_deploy", label: "Automatische Deploys", value: true }, { type: "chart", id: "fehler", label: "Fehlerrate", unit: "%", chart: "line", points: [{ label: "9:00", value: 0.2 }, { label: "10:00", value: 6.2 }] }, ], }), }); ``` Welche Elemente es gibt und welche Felder sie haben, steht in der Referenz [Elemente](https://packlets.app/docs/elemente.md). ### Auf Bedienung reagieren Tippst du den Button, legst den Schalter um oder schickst ein Eingabefeld ab, bekommt dein Webhook das Ereignis `ui`: ```json { "type": "ui", "packlet_id": "9d2f7c10-3a5b-4c8e-9f21-6b7d8e9f0a12", "message_id": "5b0c1a52-7c1e-4a43-9d0b-2f6f0c7d1e11", "element_id": "auto_deploy", "value": false, "changed_at": "2026-11-10T18:00:00.000Z" } ``` `element_id` sagt, welches Element es war, `value` den neuen Wert. Beim Button ist `value` gleich `null`. An `message_id` erkennst du, aus welcher Nachricht die Bedienung kommt. Wie dein Tool das Ereignis empfängt, steht unter [Webhook einrichten](https://packlets.app/docs/webhook.md). ### Knöpfe oder Elemente | | [Knöpfe](https://packlets.app/docs/knoepfe.md) (`buttons`) | Elemente (`elements`) | |---|---|---| | Wie oft bedienbar | einmal pro Nachricht | beliebig oft | | Was geht | Link öffnen oder eine Aktion | Button, Schalter, Eingabefeld, dazu Diagramm, Tabelle, Text | | Die Nachricht zeigt danach | welcher Knopf gewählt wurde | den aktuellen Zustand, zum Beispiel den Schalter | Beides zusammen in einer Nachricht geht auch. ### Was du wissen solltest - **Nachträglich ändern geht nicht.** Die Werte in einer Nachricht ändern sich nur, wenn du sie in der App bedienst. Dein Tool kann sie nicht aktualisieren. Schick stattdessen eine neue Nachricht. Brauchst du eine Ansicht, die immer aktuell ist, bau eine [eigene App](https://packlets.app/docs/apps.md). - **Es ist schmal.** Eine Nachricht ist schmaler als der Bildschirm. Lass Elemente am besten auf voller Breite (ohne `width`) und halt Beschriftungen kurz. Mehr dazu unter [Layout der Elemente](https://packlets.app/docs/layout.md). - **Fertige Beispiele** zum Kopieren stehen unter [Vorlagen](https://packlets.app/docs/vorlagen.md). - **Im Push** steht nur der Text. Elemente siehst du erst in der App. --- ## Layout der Elemente Das Raster mit sechs Spalten: Breiten, Höhen, Mindestgrößen und was die App selbst zurechtrückt. [Elemente in einer Nachricht](https://packlets.app/docs/nachrichten-elemente.md) stehen in einem Raster. Mit zwei Feldern bestimmst du, wie groß ein Element ist: `width` und `height`. Den Rest erledigt die App. ### Das Raster Die Fläche unter dem Text einer Nachricht ist in **6 Spalten** geteilt. `width` sagt, wie viele davon ein Element belegt: | `width` | Breite | |---|---| | `6` (Standard) | die ganze Breite | | `3` | die Hälfte | | `2` | ein Drittel | Die Elemente füllen die Zeilen von links nach rechts, in der Reihenfolge deiner Liste. Passt das nächste nicht mehr in die Zeile, beginnt eine neue. Die Liste dazu, im Feld `elements` von `POST /v1/push`: ```json [ {"type": "toggle", "id": "wartung", "label": "Wartung", "value": true, "width": 3}, {"type": "toggle", "id": "deploys", "label": "Deploys", "width": 3}, {"type": "text", "id": "neustart", "text": "Neu starten", "style": "caption"}, {"type": "button", "id": "api", "label": "API", "width": 2, "height": 56}, {"type": "button", "id": "shop", "label": "Shop", "width": 2, "height": 56}, {"type": "button", "id": "db", "label": "DB", "style": "secondary", "width": 2, "height": 56} ] ``` ### Die Höhe Ohne `height` ist ein Element so hoch, wie sein Inhalt braucht. Mit `height` gibst du eine **Mindesthöhe** in Punkten an. Braucht der Inhalt mehr, wird das Element höher. Dafür ist `height` gut: - Ein Button wird zur großen Kachel, zum Beispiel `"height": 56`. - Ein Diagramm bekommt höhere Balken, zum Beispiel `"height": 220`. Ein Text hat keine `height`. Er ist immer so hoch wie sein Inhalt. ### Mindestgrößen Damit alles lesbar und gut antippbar bleibt, hat jedes Element Grenzen: | Element | Breite mindestens | Höhe | |---|---|---| | `text` | 2 Spalten | nach Inhalt | | `button` | 2 Spalten | 44 bis 240 | | `toggle` | 3 Spalten | 64 bis 240 | | `input` | 3 Spalten | 160 bis 400 | | `chart` | 3 Spalten | 120 bis 480 | | `table` | 3 Spalten | 80 bis 800 | Gibst du weniger oder mehr an, lehnt Samy das nicht ab, sondern bringt den Wert auf die Grenze. Was geändert wurde, steht in der Antwort von `POST /v1/push` im Feld `warnings`: ```json { "message_id": "5b0c1a52-7c1e-4a43-9d0b-2f6f0c7d1e11", "warnings": ["wartung: width 2 → 3 (Mindestbreite für toggle)", "db: height 30 → 44 (Mindesthöhe für button)"] } ``` Nur eine `width` außerhalb von 1 bis 6 ist ein Fehler (`400`). ### Was die App selbst zurechtrückt Handys sind verschieden breit, und eine Nachricht ist schmaler als der Bildschirm. Die App passt dein Layout deshalb an, damit es immer aufgeräumt aussieht: - **Der Inhalt bekommt Platz.** Passt eine Beschriftung nicht in die angegebene Breite, macht die App das Element breiter. Passt es dann nicht mehr in die Zeile, rutscht es in die nächste. - **Zeilen sind immer voll.** Bleibt in einer Zeile Platz frei, werden ihre Elemente breiter. Ein einzelnes Element mit `width: 3` steht also über die ganze Breite. - **Gleiche Höhe.** Karten und Buttons in einer Zeile sind gleich hoch. Text bleibt oben links. - **Diagramme** zeigen bei vielen Punkten nur jede zweite oder dritte Beschriftung. Dein `width` ist also ein Wunsch, kein Befehl. Zwei Elemente mit `width: 3` und langen Beschriftungen stehen in einer Nachricht oft untereinander. Plane grob und sieh dir das Ergebnis auf dem Handy an. ### Tipps - Fang ohne `width` an. Alles untereinander sieht immer ordentlich aus. - Halt Beschriftungen kurz, dann passen zwei Elemente nebeneinander. - Drei Buttons mit kurzer Beschriftung, `width: 2` und `height: 56` ergeben eine Reihe Kacheln. - Ein Text mit `style: "caption"` über einer Gruppe wirkt wie eine Zwischenüberschrift. - Fertige Beispiele zum Abschauen stehen unter [Vorlagen](https://packlets.app/docs/vorlagen.md). --- ## Vorlagen Fertige Nachrichten zum Kopieren: Server-Alarm, Deploy, Bestellung, Tagesbericht und Rechnung. Fertige Nachrichten für einen Kanal. Jede Vorlage sieht hier so aus wie in Samy, und Schalter, Buttons und Eingabefelder kannst du direkt ausprobieren. So nutzt du eine Vorlage: JSON kopieren, mit `POST /v1/push` schicken, Texte und IDs an dein Tool anpassen. Wie das geht, steht ganz unten unter [Schicken](#schicken). Was die einzelnen Felder bedeuten, steht unter [Knöpfe](https://packlets.app/docs/knoepfe.md) und [Elemente](https://packlets.app/docs/elemente.md). ### Server antwortet nicht Dein Uptime-Check merkt, dass ein Dienst ausfällt. Die Meldung kommt mit Ton, auch in der Ruhezeit. Ein Knopf startet den Dienst neu, der zweite öffnet die Statusseite. ```json { "title": "shop-api antwortet nicht", "text": "Seit **9:38 Uhr** kein Health-Check mehr. Letzter Status: 502.", "priority": "high", "elements": [ { "type": "table", "id": "checks", "label": "Letzte Checks", "columns": ["Zeit", "Status", "ms"], "rows": [["9:36", "200", 182], ["9:37", "200", 240], ["9:38", "502", 30012], ["9:39", "502", 30008]] } ], "buttons": [ { "id": "restart", "label": "Neu starten" }, { "id": "status", "label": "Status", "url": "https://status.example.com" } ] } ``` Tippst du **Neu starten**, bekommt dein Webhook das Ereignis `button` mit `button_id: "restart"`. ### Deploy fehlgeschlagen Deine CI meldet einen roten Build. Die Tabelle zeigt, welche Tests scheitern. Den Button **Erneut starten** kannst du mehrmals tippen, anders als einen Knopf. ```json { "title": "Deploy fehlgeschlagen", "text": "**shop-api** auf `main`, Commit a3f9c21 von Jonas.", "priority": "high", "elements": [ { "type": "table", "id": "tests", "label": "Fehlgeschlagene Tests", "columns": ["Test", "Dauer"], "rows": [["checkout › Gutschein", "1,2 s"], ["checkout › Versand", "0,8 s"]] }, { "type": "button", "id": "retry", "label": "Erneut starten", "width": 3 }, { "type": "button", "id": "rollback", "label": "Zurückrollen", "style": "secondary", "width": 3 } ], "buttons": [ { "id": "log", "label": "Log öffnen", "url": "https://ci.example.com/runs/4711" } ] } ``` ### Neue Bestellung Dein Shop meldet jede Bestellung. Der Knopf markiert sie als versendet, der Link öffnet sie im Backend. ```json { "title": "Neue Bestellung #1049", "text": "Tim R. aus Leipzig, **129,00 €**, bezahlt mit PayPal.", "elements": [ { "type": "table", "id": "artikel", "columns": ["Artikel", "Menge", "€"], "rows": [["Kaffeemühle Classic", 1, 99], ["Bohnen Espresso 1 kg", 2, 15]] } ], "buttons": [ { "id": "shipped", "label": "Versendet" }, { "id": "open", "label": "Öffnen", "url": "https://shop.example.com/admin/orders/1049" } ] } ``` ### Tagesbericht Ein Cronjob schickt jeden Morgen die Zahlen von gestern. Mit `priority: "silent"` kommt er ohne Ton. ```json { "title": "Tagesbericht", "text": "## Gestern\n- **23 Bestellungen**, 1.204 € Umsatz\n- 3 Deploys, alle erfolgreich\n- Backup um 3:00 fertig", "priority": "silent", "elements": [ { "type": "chart", "id": "umsatz", "label": "Umsatz der Woche", "unit": "€", "height": 160, "points": [ { "label": "Do", "value": 1310 }, { "label": "Fr", "value": 1580 }, { "label": "Sa", "value": 720 }, { "label": "So", "value": 640 }, { "label": "Mo", "value": 860 }, { "label": "Di", "value": 1204 } ] } ] } ``` ### Rechnung freigeben Eine neue Rechnung wartet auf deine Freigabe. Zwei Knöpfe entscheiden, das Eingabefeld schickt eine Rückfrage an dein Tool. Musst du viele Rechnungen nacheinander prüfen, ist eine [eigene App](https://packlets.app/docs/apps.md) bequemer. ```json { "title": "Rechnung von Hosting GmbH", "text": "**189,00 €**, fällig am 20.11. Leistung: Server November.", "elements": [ { "type": "input", "id": "frage", "label": "Rückfrage an die Buchhaltung", "placeholder": "z. B. Warum teurer als im Oktober?", "button": "Fragen" } ], "buttons": [ { "id": "approve", "label": "Freigeben" }, { "id": "reject", "label": "Ablehnen" }, { "id": "pdf", "label": "PDF", "url": "https://example.com/rechnungen/2026-118.pdf" } ] } ``` ### Schicken Speicher das JSON einer Vorlage als `vorlage.json` und schick es als Nachricht: ```bash curl https://api.packlets.app/v1/push \ -H "Authorization: Bearer $SAMY_KEY" \ -H "Content-Type: application/json" \ -d @vorlage.json ``` ```python import json, os, requests with open("vorlage.json") as f: requests.post("https://api.packlets.app/v1/push", timeout=10, json=json.load(f), headers={"Authorization": f"Bearer {os.environ['SAMY_KEY']}"}).raise_for_status() ``` ```typescript import vorlage from "./vorlage.json" with { type: "json" }; const res = await fetch("https://api.packlets.app/v1/push", { method: "POST", headers: { Authorization: `Bearer ${process.env.SAMY_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify(vorlage), }); if (!res.ok) throw new Error(`Samy ${res.status}: ${await res.text()}`); ``` Danach kommt jeder Tipp auf einen Knopf als Webhook-Ereignis `button`, jede Bedienung eines Elements als Ereignis `ui`, beide mit der `message_id` der Nachricht. Wie dein Tool darauf reagiert, steht unter [Webhook einrichten](https://packlets.app/docs/webhook.md#5-auf-ereignisse-reagieren). --- ## Ohne Code: Slack-Adresse Tools, die nur an Slack schicken können, schicken mit dieser Adresse an Samy. Ohne eine Zeile Code. Viele Tools können keine eigene Push-App anbinden, haben aber eine Einstellung „Slack-Benachrichtigung“ oder „Slack Webhook“. Dort trägst du statt der Slack-Adresse die Slack-Adresse deines Packlets ein. Samy nimmt die Meldung an, als wäre Samy Slack, und schickt sie als Push auf dein Handy. Du schreibst dafür keinen Code. ### In der App verbinden Tipp beim Verbinden auf **Integrationen** und wähle **Slack**. Name und Tier sind schon ausgefüllt. Danach zeigt Samy statt Code gleich die fertige Adresse zum Kopieren; die trägst du in deinem Tool bei Slack ein. Samy liest dabei nicht in deinem Slack mit, es nimmt nur an, was dein Tool schickt. ### Deine Adresse ```text https://api.packlets.app/v1/slack/ ``` Statt `` setzt du den Schlüssel deines Packlets ein, also den, der mit `samy_` beginnt. Mehr braucht das Tool nicht. > Der Schlüssel steht hier in der Adresse, weil diese Tools keinen eigenen Header schicken können. Behandle die ganze Adresse deshalb wie ein Passwort. Erzeugst du in der App einen neuen Schlüssel, gilt die alte Adresse sofort nicht mehr. ### Wo das funktioniert Überall, wo du eine Slack-Webhook-Adresse („Incoming Webhook“) eintragen kannst. Zum Beispiel: | Bereich | Tools | |---|---| | Überwachung | Uptime Kuma, Grafana, Prometheus Alertmanager, Sentry, Healthchecks.io | | Code und Deploys | GitHub Actions, GitLab, Jenkins, Railway, Netlify | | Automatisierung | n8n, Zapier, Make, Home Assistant | | Webseiten | Formular-Plugins für WordPress mit Slack-Option, Shop-Systeme | | Eigene Skripte | Alles, was schon an Slack schickt: Adresse tauschen, fertig | ### Schnell testen ```bash curl https://api.packlets.app/v1/slack/ \ -H "Content-Type: application/json" \ -d '{"text":"*Backup* fehlgeschlagen auf "}' ``` Kommt `ok` zurück, ist die Nachricht da. Im Packlet-Protokoll steht der Aufruf mit „Slack:“ davor. ### Was Samy übernimmt Samy versteht die üblichen Slack-Formate und macht daraus eine normale [Nachricht](https://packlets.app/docs/senden.md): | Slack | In Samy | |---|---| | `text` | Text der Nachricht. Gibt es `blocks`, ist `text` dort nur die Kurzfassung und wird nicht gezeigt. | | `*fett*` | **fett** | | `` | Beschriftung im Text. Der erste Link wird zum Link der Nachricht. | | Erwähnungen wie `` oder `<@U123>` | fallen weg | | Block `header` | Titel | | Block `section` mit `text` und `fields` | Absatz, Felder als Liste | | Block `context` | kleiner Absatz, Teile mit `·` getrennt | | Block `image` | Bild der Nachricht | | Block `actions` mit Buttons, die eine `url` haben | bis zu 3 [Knöpfe](https://packlets.app/docs/knoepfe.md), die den Link öffnen | | `attachments` mit `title`, `title_link`, `pretext`, `text`, `fields`, `image_url` | Titel, Link, Text, Liste und Bild | | `attachments` mit `color: "danger"` | [Priorität](https://packlets.app/docs/push.md) `high` | | Formular mit `payload=` | wie JSON | Alles andere, etwa Menüs, Datumswähler oder Formatierungen, die Samy nicht kennt, lässt Samy weg, statt die Meldung abzulehnen. Zu lange Texte werden gekürzt, Links ohne `https` fallen weg. Ein Beispiel mit Blocks: ```json { "text": "Neue Bestellung #1042", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Neue Bestellung #1042" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*Anna Weber* hat bestellt" }, "fields": [{ "type": "mrkdwn", "text": "*Betrag:* 49 €" }] }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "Bestellung öffnen" }, "url": "https://shop.example.com/orders/1042" } ] } ] } ``` In Samy wird daraus eine Nachricht mit dem Titel „Neue Bestellung #1042“, dem Text „**Anna Weber** hat bestellt“ mit dem Punkt „**Betrag:** 49 €“ und einem Knopf „Bestellung öffnen“. ### Antworten Samy antwortet wie Slack mit kurzem Text, weil manche Tools genau das prüfen: | Status | Antwort | Bedeutung | |---|---|---| | `200` | `ok` | Gespeichert, der Push ist unterwegs. | | `400` | `invalid_payload` | Kein Text gefunden, weder in `text` noch in `blocks` oder `attachments`. | | `401` | JSON mit `invalid_key` | Schlüssel falsch oder Packlet entfernt. | | `429` | JSON mit `rate_limited` | Zu viele Aufrufe. Es gilt dasselbe [Limit](https://packlets.app/docs/api.md#rate-limit) wie bei `POST /v1/push`. | ### Grenzen - Knöpfe öffnen nur Links. Knöpfe, die deinem Tool antworten, gibt es mit [`POST /v1/push`](https://packlets.app/docs/knoepfe.md) und einem [Webhook](https://packlets.app/docs/webhook.md). - Elemente wie Schalter oder Diagramme gibt es über diese Adresse nicht. - Die Adresse nimmt nur Nachrichten an. Die Seite einer App setzt du mit [`PUT /v1/ui`](https://packlets.app/docs/api-ui.md). --- ## Ohne Code: Discord-Adresse Tools mit Discord-Option schicken mit dieser Adresse an Samy. Ohne Code und ohne Discord-Konto. Viele Tools haben eine Einstellung „Discord-Benachrichtigung“ oder „Discord Webhook“. Dort trägst du statt der Discord-Adresse die Discord-Adresse deines Packlets ein. Samy nimmt die Meldung an, als wäre Samy Discord, und schickt sie als Push auf dein Handy. Du schreibst dafür keinen Code und brauchst kein Discord-Konto. ### In der App verbinden Tipp beim Verbinden auf **Integrationen** und wähle **Discord**. Name und Tier sind schon ausgefüllt. Danach zeigt Samy statt Code gleich die fertige Adresse zum Kopieren; die trägst du in deinem Tool bei Discord ein. ### Deine Adresse ```text https://api.packlets.app/v1/discord/ ``` Statt `` setzt du den Schlüssel deines Packlets ein, also den, der mit `samy_` beginnt. > Der Schlüssel steht hier in der Adresse, weil diese Tools keinen eigenen Header schicken können. Behandle die ganze Adresse deshalb wie ein Passwort. Erzeugst du in der App einen neuen Schlüssel, gilt die alte Adresse sofort nicht mehr. ### Wo das funktioniert Überall, wo du eine beliebige Discord-Webhook-Adresse eintragen kannst. Zum Beispiel: | Bereich | Tools | |---|---| | Überwachung | Uptime Kuma, Grafana, Healthchecks.io, Gatus, Netdata | | Medien | Sonarr, Radarr, Lidarr, Jellyfin, Plex (über Tautulli), Overseerr | | Server | Proxmox, Watchtower, Unraid, TrueNAS | | Code und Deploys | GitHub Actions, GitLab, Coolify | | Automatisierung | n8n, Make, Home Assistant | Einige Tools prüfen, ob die Adresse mit `https://discord.com/api/webhooks/` beginnt. Dort geht es nicht; nimm dann die [ntfy-Adresse](https://packlets.app/docs/ntfy.md) oder die [Slack-Adresse](https://packlets.app/docs/slack.md), falls das Tool sie anbietet. ### Schnell testen ```bash curl https://api.packlets.app/v1/discord/ \ -H "Content-Type: application/json" \ -d '{"content":"**Backup** fehlgeschlagen auf [server-1](https://status.example.com)"}' ``` Wie Discord antwortet Samy mit `204` ohne Inhalt. Im Packlet-Protokoll steht der Aufruf mit „Discord:“ davor. ### Was Samy übernimmt Samy versteht die üblichen Discord-Formate und macht daraus eine normale [Nachricht](https://packlets.app/docs/senden.md): | Discord | In Samy | |---|---| | `content` | Text der Nachricht | | `**fett**`, `# Überschrift`, `- Punkt` | wie in Samy | | `[Beschriftung](https://…)` | Beschriftung im Text. Der erste Link wird zum Link der Nachricht. | | `*kursiv*`, `~~durch~~`, `` `Code` ``, `\|\|Spoiler\|\|` | nur der Text | | Erwähnungen wie `@everyone` oder `<@123>` | fallen weg | | Zeitstempel wie `` | Datum und Uhrzeit | | `embeds[].title` (sonst `author.name`) | Titel; weitere Embeds als fette Zeile | | `embeds[].url` | Link der Nachricht | | `embeds[].description` | Absatz | | `embeds[].fields` | Liste „Name: Wert“ | | `embeds[].footer.text` | kleiner Absatz am Ende | | `embeds[].image` oder `thumbnail` | Bild der Nachricht | | `embeds[].color` in Rot | [Priorität](https://packlets.app/docs/push.md) `high` | | `flags: 4096` (leise Nachricht) | Priorität `silent` | | Link-Knöpfe in `components` | bis zu 3 [Knöpfe](https://packlets.app/docs/knoepfe.md), die den Link öffnen | | Formular mit `payload_json=` | wie JSON | `username`, `avatar_url` und `tts` lässt Samy weg; Name und Bild kommen vom Packlet. Zu lange Texte werden gekürzt, Links ohne `https` fallen weg. ### Antworten | Status | Antwort | Bedeutung | |---|---|---| | `204` | leer | Gespeichert, der Push ist unterwegs. | | `200` | JSON mit `id` | Wie `204`, wenn die Adresse mit `?wait=true` endet. | | `400` | JSON mit `code` `50006` | Kein Text gefunden, weder in `content` noch in `embeds`. | | `401` | JSON mit `invalid_key` | Schlüssel falsch oder Packlet entfernt. | | `429` | JSON mit `rate_limited` | Zu viele Aufrufe. Es gilt dasselbe [Limit](https://packlets.app/docs/api.md#rate-limit) wie bei `POST /v1/push`. | ### Grenzen - Dateien (Uploads mit `multipart/form-data`) nimmt Samy nicht an. Bilder gehen als Link in `embeds[].image`. - Knöpfe öffnen nur Links. Knöpfe, die deinem Tool antworten, gibt es mit [`POST /v1/push`](https://packlets.app/docs/knoepfe.md) und einem [Webhook](https://packlets.app/docs/webhook.md). - Nachrichten bearbeiten oder löschen (`PATCH`/`DELETE` auf die Webhook-Adresse) gibt es nicht. --- ## Ohne Code: ntfy Samy versteht die Aufrufe von ntfy: Server ist die Samy-Adresse, Topic dein Schlüssel. [ntfy](https://ntfy.sh) ist ein beliebter Weg, Push-Nachrichten aus Skripten und Server-Tools zu schicken. Samy versteht dieselben Aufrufe: Viele Tools mit ntfy-Option schicken damit direkt an Samy. Server ist die Samy-Adresse, Topic der Schlüssel deines Packlets. Du schreibst dafür keinen Code. Einen ntfy-Server oder die ntfy-App brauchst du nicht. ### In der App verbinden Tipp beim Verbinden auf **Integrationen** und wähle **ntfy**. Name und Tier sind schon ausgefüllt. Danach zeigt Samy **Server** und **Topic** zum Kopieren; die trägst du in deinem Tool bei ntfy ein. ### Server und Topic | Feld im Tool | Wert | |---|---| | Server (Server-URL) | `https://api.packlets.app` | | Topic | `` | Fragt das Tool nach einer einzigen Adresse, ist es beides zusammen: `https://api.packlets.app/`. > Das Topic ist hier dein Schlüssel. Behandle es wie ein Passwort. Erzeugst du in der App einen neuen Schlüssel, gilt das alte Topic sofort nicht mehr. Hat das Tool ein eigenes Feld für ein Access-Token oder ein Passwort, kannst du den Schlüssel auch dort eintragen und ein beliebiges Topic wählen, zum Beispiel `backup`. Samy nimmt den Schlüssel dann aus `Authorization: Bearer …` oder aus dem Passwort der Basic-Anmeldung. ### Wo das funktioniert | Bereich | Tools | |---|---| | Smart Home | Home Assistant, Node-RED, ioBroker | | Überwachung | Uptime Kuma, Healthchecks.io, Gatus, Changedetection.io, Beszel | | Server | Watchtower, Proxmox, Unraid, Diun, Backrest, borgmatic | | Medien | Sonarr, Radarr, Jellyseerr | | Alles mit Apprise | Apprise-Adressen `ntfys://…` (ohne eigenen Pfad im Server) | | Eigene Skripte | Alles, was schon an ntfy schickt: Server tauschen, fertig | ### Schnell testen ```bash curl -d "Backup fertig 🎉" https://api.packlets.app/ ``` Mit Titel, Priorität und Link: ```bash curl https://api.packlets.app/ \ -H "Title: NAS" \ -H "Priority: high" \ -H "Tags: warning" \ -H "Click: https://nas.example.com" \ -d "Platte zu 95 % voll" ``` Samy antwortet wie ntfy mit JSON (`id`, `time`, `event`, `message`). Im Packlet-Protokoll steht der Aufruf mit „ntfy:“ davor. ### Die drei Wege | Weg | Beispiel | |---|---| | Text als Body, Angaben in Headern | `POST https://api.packlets.app/` (auch `PUT`) | | Alles in der Adresse | `GET https://api.packlets.app//publish?message=Hallo&title=Test` (auch `/send`, `/trigger`) | | JSON an den Server | `POST https://api.packlets.app/` mit `{"topic":"","message":"Hallo"}` | Wie bei ntfy ist beim ersten Weg der Body immer die Nachricht, auch wenn er wie JSON aussieht. ### Was Samy übernimmt Header gibt es in allen Schreibweisen von ntfy (`Title`, `X-Title`, `t`); dieselben Namen gehen als Parameter in der Adresse. | ntfy | In Samy | |---|---| | Body oder `Message` | Text der Nachricht. Leer: „Ausgelöst“. | | `Title` | Titel | | `Priority` `1`–`2` (`min`, `low`) | [Priorität](https://packlets.app/docs/push.md) `silent` | | `Priority` `3` (`default`) | `normal` | | `Priority` `4`–`5` (`high`, `urgent`, `max`) | `high` | | `Tags` wie `warning`, `tada`, `white_check_mark` | Emoji vor dem Titel (sonst vor dem Text). Tags ohne Emoji fallen weg. | | `Click` | Link der Nachricht | | `Attach` mit Bild-Link (`.png`, `.jpg`, …) | Bild der Nachricht; andere Links werden zum Link | | `Actions` mit `view` | bis zu 3 [Knöpfe](https://packlets.app/docs/knoepfe.md), die den Link öffnen | | `Markdown: yes` | Formatierung wie in Samy, Links `[Text](url)` werden zum Link | | Umlaute in Headern (RFC 2047 oder UTF-8) | richtig | ### Antworten | Status | Antwort | Bedeutung | |---|---|---| | `200` | JSON mit `id` und `event: "message"` | Gespeichert, der Push ist unterwegs. | | `400` | JSON mit `code` und `error` | Keine gültige Nachricht, kein JSON beim JSON-Weg oder eine Datei als Anhang. | | `401` | JSON mit `invalid_key` | Schlüssel falsch oder Packlet entfernt. | | `404` | JSON mit `code` `40401` | Das Topic ist kein Schlüssel und es gibt auch keinen im Token oder Passwort. | | `429` | JSON mit `rate_limited` | Zu viele Aufrufe. Es gilt dasselbe [Limit](https://packlets.app/docs/api.md#rate-limit) wie bei `POST /v1/push`. | ### Grenzen - Samy nimmt nur Nachrichten an. Abonnieren (`/json`, `/sse`, `/ws`) gibt es nicht; die Nachrichten landen in der Samy-App. - Dateien als Anhang hochladen geht nicht. Bilder gehen als Link mit `Attach`. - `http`- und `broadcast`-Aktionen, `Delay`, `Email`, `Call` und `Icon` lässt Samy weg. --- ## Push, Ton und Ruhezeit Wann eine Nachricht als Push erscheint, wann mit Ton und wann sie still bleibt. Jede Nachricht steht im Kanal. Ob sie zusätzlich als Push auf dem Sperrbildschirm erscheint und ob sie dabei einen Ton macht, entscheiden zwei Dinge: das Feld `priority` der Nachricht und die Einstellungen des Packlets in der App. ### Priorität Dein Tool setzt `priority` beim [Senden](https://packlets.app/docs/senden.md): ```json { "title": "Server", "text": "Die Festplatte ist zu 95 % voll.", "priority": "high" } ``` | `priority` | Push | Ton | In der Ruhezeit | |---|---|---|---| | `normal` (Standard) | ja | ja | kein Push | | `high` | ja | ja | **Push kommt trotzdem** | | `silent` | ja | nein | kein Push | - `silent` heißt nicht unsichtbar. Der Push erscheint, nur ohne Ton. Gut für Berichte, die nicht stören sollen. - `high` ist für alles, was nicht warten kann. Auf dem iPhone kommt es als dringliche Mitteilung an. ### Einstellungen in der App Du hast immer das letzte Wort. Tippe im Kanal oben auf Avatar oder Namen, dann siehst du die Einstellungen des Packlets: | Einstellung | Wirkung | |---|---| | **Push aus** oder **Kanal stumm** | Kein Push, auch nicht bei `high`. | | **Ton aus** | Pushes kommen ohne Ton, auch bei `high`. | | **Ruhezeit** | In diesem Zeitraum kommt nur `high` als Push. Sie gilt in der Zeitzone deines Handys und darf über Mitternacht gehen. | > **Hinweis:** Ein Push, der wegen Ruhezeit oder Einstellungen ausfällt, wird nicht nachgeholt. Die Nachricht steht aber im Kanal, und der Kanal zeigt sie als ungelesen. ### Was im Push steht | Teil | Inhalt | |---|---| | Titel | Der Name des Packlets. Hat die Nachricht einen `title`, steht er dahinter (Android) oder als zweite Zeile darunter (iPhone). | | Text | Der Anfang von `text`, höchstens 900 Zeichen, ohne Formatierungszeichen. | | Bild | Das Bild aus `image`, falls angegeben. | Mehrere Pushes desselben Packlets fasst das iPhone zu einer Gruppe zusammen. Tippst du einen Push an, öffnet sich der Kanal. ### Wenn kein Push kommt Der Aufruf antwortet auch dann mit `200`, wenn kein Push rausgeht. Die häufigsten Gründe stehen unter [Fehlersuche](https://packlets.app/docs/fehlersuche.md#die-nachricht-ist-da-aber-kein-push). --- ## Webhook einrichten So erfährt dein Tool, was du in der App tust: Empfänger bauen, Signatur prüfen, testen. Bisher hat nur dein Tool mit Samy gesprochen. Mit einem Webhook geht es auch zurück: Tippst du einen Knopf, legst einen Schalter um oder schreibst im Chat, ruft Samy dein Tool auf. Am Ende dieser Seite hast du einen kleinen Empfänger, der Ereignisse annimmt, ihre Echtheit prüft und darauf reagiert. Auf ihm bauen [Knöpfe](https://packlets.app/docs/knoepfe.md), [Elemente in Nachrichten](https://packlets.app/docs/nachrichten-elemente.md) und [Antworten](https://packlets.app/docs/chat.md) auf. **Du brauchst:** ein Packlet mit Schlüssel und einen Ort, an dem dein Tool unter einer öffentlichen `https`-Adresse erreichbar ist. ### 1. Einen Empfänger bauen Ein Webhook-Empfänger ist ein kleiner Webserver mit einer einzigen Adresse, die `POST`-Anfragen annimmt. Dieser hier gibt jedes Ereignis aus: ```python # pip install flask from flask import Flask, request app = Flask(__name__) @app.post("/samy") def samy_webhook(): event = request.get_json() print(event) return "", 204 app.run(port=8080) ``` ```typescript // Node.js 18 oder neuer, ohne weitere Pakete import { createServer } from "node:http"; createServer(async (req, res) => { let body = ""; for await (const chunk of req) body += chunk; console.log(JSON.parse(body)); res.writeHead(204).end(); }).listen(8080); ``` ### 2. Öffentlich erreichbar machen Samy muss deinen Empfänger aus dem Internet erreichen. Die Adresse muss mit `https://` beginnen. Adressen wie `localhost` oder `192.168.…` lehnt Samy ab. - **Auf einem Server** mit eigener Domain ist das schon so. - **Auf deinem Rechner** hilft zum Testen ein Tunnel, zum Beispiel `cloudflared tunnel --url http://localhost:8080` oder `ngrok http 8080`. Beide geben dir eine öffentliche `https`-Adresse, die auf deinen Rechner zeigt. ### 3. Die Adresse in Samy eintragen Die Webhook-URL trägst du beim Anlegen des Packlets ein oder später in seinen Einstellungen: im Kanal oben auf Avatar oder Namen tippen. Im Beispiel wäre das `https://deine-adresse.example/samy`. Zum Testen schickst du dir eine Nachricht mit einem Aktions-Knopf und tippst ihn an: ```bash curl https://api.packlets.app/v1/push \ -H "Authorization: Bearer $SAMY_KEY" \ -H "Content-Type: application/json" \ -d '{"text": "Kommt der Webhook an?", "buttons": [{"id": "ja", "label": "Ja"}]}' ``` Dein Empfänger gibt jetzt das Ereignis aus: ```json { "type": "button", "packlet_id": "9d2f7c10-3a5b-4c8e-9f21-6b7d8e9f0a12", "message_id": "5b0c1a52-7c1e-4a43-9d0b-2f6f0c7d1e11", "button_id": "ja", "pressed_at": "2026-11-09T18:00:00.000Z" } ``` Kommt nichts an? Im Protokoll des Packlets siehst du jeden Versuch mit dem Status deiner Antwort. Mehr unter [Fehlersuche](https://packlets.app/docs/fehlersuche.md#der-webhook-kommt-nicht-an). ### 4. Signatur prüfen Deine Adresse ist öffentlich. Jeder, der sie kennt, könnte dir gefälschte Ereignisse schicken. Deshalb unterschreibt Samy jede Anfrage im Header `X-Samy-Signature`, und dein Tool prüft die Unterschrift, bevor es etwas tut. So wird gerechnet: 1. Das Geheimnis ist der SHA-256-Hash deines Schlüssels, als Hex-Text. 2. Mit diesem Geheimnis bildest du den HMAC-SHA256 über den Body, genau so, wie er angekommen ist. 3. Das Ergebnis als Hex-Text, mit `sha256=` davor, muss dem Header gleichen. ```python import hashlib, hmac, os from flask import Flask, request app = Flask(__name__) SECRET = hashlib.sha256(os.environ["SAMY_KEY"].encode()).hexdigest().encode() def is_from_samy(raw_body: bytes, signature: str) -> bool: expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature or "") @app.post("/samy") def samy_webhook(): if not is_from_samy(request.get_data(), request.headers.get("X-Samy-Signature", "")): return "", 401 event = request.get_json() print(event) return "", 204 app.run(port=8080) ``` ```typescript import { createHash, createHmac, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; const secret = createHash("sha256").update(process.env.SAMY_KEY ?? "").digest("hex"); function isFromSamy(rawBody: string, signature = ""): boolean { const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")); const given = Buffer.from(signature); return expected.length === given.length && timingSafeEqual(expected, given); } createServer(async (req, res) => { let body = ""; for await (const chunk of req) body += chunk; if (!isFromSamy(body, req.headers["x-samy-signature"] as string | undefined)) { res.writeHead(401).end(); return; } console.log(JSON.parse(body)); res.writeHead(204).end(); }).listen(8080); ``` > **Achtung:** Rechne über den Body, wie er angekommen ist, nicht über das JSON, das du daraus neu erzeugst. Schon ein anderes Leerzeichen ändert die Signatur. Erzeugst du in der App einen neuen Schlüssel, ändert sich auch das Geheimnis. Dein Tool braucht dann den neuen Schlüssel. Löst dein Tool etwas aus, das nicht doppelt passieren darf, prüf zusätzlich `timestamp` und `event_id` aus dem Body: [Schutz vor Wiederholung](https://packlets.app/docs/ereignisse.md#schutz-vor-wiederholung). ### 5. Auf Ereignisse reagieren Es gibt drei Ereignisse. Das Feld `type` sagt, welches es ist: | `type` | Wann | Wichtige Felder | |---|---|---| | `button` | Du tippst einen Aktions-[Knopf](https://packlets.app/docs/knoepfe.md). | `message_id`, `button_id` | | `ui` | Du bedienst ein [Element in einer Nachricht](https://packlets.app/docs/nachrichten-elemente.md). | `message_id`, `element_id`, `value` | | `message` | Du schreibst im [Chat](https://packlets.app/docs/chat.md). | `text` | Alle Felder stehen in der Referenz [Webhook-Ereignisse](https://packlets.app/docs/ereignisse.md). Ersetz im Empfänger die Ausgabe durch deine Logik: ```python event = request.get_json() if event["type"] == "button" and event["button_id"] == "restart": starte_neu(event["message_id"]) # deine Funktion elif event["type"] == "ui" and event["element_id"] == "rollback": rollback() # deine Funktion elif event["type"] == "message": antworte(event["text"]) # deine Funktion return "", 204 ``` ```typescript const event = JSON.parse(body); res.writeHead(204).end(); // erst antworten, dann arbeiten if (event.type === "button" && event.button_id === "restart") await starteNeu(event.message_id); // deine Funktion else if (event.type === "ui" && event.element_id === "rollback") await rollback(); // deine Funktion else if (event.type === "message") await antworte(event.text); // deine Funktion ``` Ereignisse, die dein Tool nicht kennt, ignoriert es am besten. So läuft es weiter, wenn Samy neue bekommt. ### Schnell antworten Samy wartet höchstens **10 Sekunden** auf deine Antwort. Jeder Status `2xx` gilt als angekommen, der Inhalt deiner Antwort wird nicht gelesen. Dauert die Arbeit länger, zum Beispiel eine KI-Antwort, dann bestätige sofort und arbeite danach. Das Ergebnis schickst du mit `POST /v1/push`, sobald es fertig ist. ### Doppelte Ereignisse Antwortet dein Tool nicht, zu spät oder mit einem Serverfehler, versucht Samy es bis zu fünfmal erneut, über etwa zehn Minuten verteilt. Dasselbe Ereignis kann also mehrfach ankommen, auch wenn dein Tool es beim ersten Mal schon verarbeitet hat. Darf eine Aktion nur einmal laufen, merk dir, was du schon gesehen hast: | Ereignis | Eindeutig durch | |---|---| | `button` | `message_id` und `button_id` | | `message` | `message_id` | | `ui` | `message_id`, `element_id` und `changed_at` | Bei Schaltern ist das meist egal: Denselben Wert zweimal zu setzen schadet nicht. Ein Neustart oder eine Zahlung dagegen darf nur einmal laufen. ### Wie es weitergeht - Mit deinem Tool schreiben: [Chat-Bot bauen](https://packlets.app/docs/chat.md). - Schalter, Eingabefelder und Buttons in Nachrichten: [Elemente in Nachrichten](https://packlets.app/docs/nachrichten-elemente.md). - Alle Felder, Header und Regeln der Zustellung: [Webhook-Ereignisse](https://packlets.app/docs/ereignisse.md). --- ## Chat-Bot bauen Mit deinem Tool schreiben: Deine Nachricht kommt per Webhook, die Antwort über den Senden-Aufruf. In einem Kanal mit Antworten schreibst du mit deinem Tool, zum Beispiel mit deinem eigenen KI-Agenten. Neues musst du dafür kaum lernen: Deine Nachricht kommt als Webhook-Ereignis, die Antwort ist eine ganz normale Nachricht. **Du brauchst:** [Nachricht senden](https://packlets.app/docs/senden.md) und einen [Webhook](https://packlets.app/docs/webhook.md). ### So läuft ein Gespräch 1. Du schreibst in der App. Deine Nachricht steht sofort rechts im Verlauf. 2. Samy schickt sie als Ereignis `message` an deinen Webhook. 3. Dein Tool antwortet mit `POST /v1/push`. Die Antwort erscheint links im Verlauf und als Push. ### 1. Antworten einschalten Leg in der App ein neues Packlet an, wähle **Mir Bescheid geben** und schalte **Antworten erlauben** ein. Trag gleich die Webhook-URL deines Tools ein. Ohne sie kommen deine Nachrichten bei deinem Tool nicht an. Einen bestehenden Kanal machst du genauso zum Chat: In den Einstellungen des Packlets **Antworten erlauben** einschalten. Schaltest du es aus, verschwindet das Eingabefeld wieder; der Verlauf bleibt. ### 2. Nachrichten empfangen Schreibst du im Kanal, bekommt dein Webhook dieses Ereignis: ```json { "type": "message", "packlet_id": "9d2f7c10-3a5b-4c8e-9f21-6b7d8e9f0a12", "message_id": "c41e8a77-0d1f-4e6a-b2c3-91a4d5e6f708", "text": "Läuft der Shop wieder?", "sent_at": "2026-11-10T08:15:00.000Z" } ``` In `text` steht, was du geschrieben hast, bis zu 4.000 Zeichen. ### 3. Antworten Die Antwort ist derselbe Aufruf wie bei jeder Nachricht. Der Schlüssel sorgt dafür, dass sie im richtigen Kanal landet: ```bash curl https://api.packlets.app/v1/push \ -H "Authorization: Bearer $SAMY_KEY" \ -H "Content-Type: application/json" \ -d '{"text": "Ja. Seit 9:52 Uhr antworten alle Server."}' ``` Alles, was eine Nachricht kann, geht auch hier: [Formatierung](https://packlets.app/docs/senden.md#formatierung), [Knöpfe](https://packlets.app/docs/knoepfe.md) und [Elemente](https://packlets.app/docs/nachrichten-elemente.md). Einen Titel lässt du bei Antworten meist weg. ### Ein vollständiger Bot Dieser Bot prüft die Signatur und antwortet auf jede Nachricht: ```python import hashlib, hmac, os, requests from flask import Flask, request app = Flask(__name__) SAMY_KEY = os.environ["SAMY_KEY"] SECRET = hashlib.sha256(SAMY_KEY.encode()).hexdigest().encode() def reply(text): requests.post("https://api.packlets.app/v1/push", timeout=10, headers={"Authorization": f"Bearer {SAMY_KEY}"}, json={"text": text}).raise_for_status() @app.post("/samy") def samy_webhook(): expected = "sha256=" + hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, request.headers.get("X-Samy-Signature", "")): return "", 401 event = request.get_json() if event["type"] == "message": reply(f"Du hast geschrieben: {event['text']}") return "", 204 app.run(port=8080) ``` ```typescript import { createHash, createHmac, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; const SAMY_KEY = process.env.SAMY_KEY ?? ""; const secret = createHash("sha256").update(SAMY_KEY).digest("hex"); async function reply(text: string) { const res = await fetch("https://api.packlets.app/v1/push", { method: "POST", headers: { Authorization: `Bearer ${SAMY_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ text }), }); if (!res.ok) throw new Error(`Samy ${res.status}: ${await res.text()}`); } createServer(async (req, res) => { let body = ""; for await (const chunk of req) body += chunk; const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(body, "utf8").digest("hex")); const given = Buffer.from(String(req.headers["x-samy-signature"] ?? "")); if (expected.length !== given.length || !timingSafeEqual(expected, given)) { res.writeHead(401).end(); return; } const event = JSON.parse(body) as { type: string; text?: string }; res.writeHead(204).end(); // erst antworten, dann arbeiten if (event.type === "message") await reply(`Du hast geschrieben: ${event.text}`); }).listen(8080); ``` ### Wenn die Antwort länger dauert Samy wartet nur 10 Sekunden auf die Antwort des Webhooks. Eine KI braucht oft länger. Trenn deshalb beides: 1. Bestätige den Webhook sofort mit einem Status `2xx`. 2. Lass die Antwort im Hintergrund entstehen. 3. Schick sie mit `POST /v1/push`, sobald sie fertig ist. Bestätigst du zu spät, schickt Samy dasselbe Ereignis noch einmal, und dein Bot antwortet doppelt. Merk dir deshalb die `message_id` der Nachrichten, die du schon bearbeitest. Mehr dazu unter [Doppelte Ereignisse](https://packlets.app/docs/webhook.md#doppelte-ereignisse). ### Gut zu wissen - **Von sich aus schreiben** darf dein Tool jederzeit, zum Beispiel eine Erinnerung. Es muss nicht auf eine Nachricht warten. - **Den Verlauf** bekommt dein Tool nicht mitgeschickt. Jedes Ereignis enthält nur die eine neue Nachricht. Braucht dein Bot ein Gedächtnis, speichert er die Nachrichten selbst. - **Deine Texte sind Eingaben von außen.** Führ sie nicht ungeprüft als Befehl aus, siehe [Schlüssel und Sicherheit](https://packlets.app/docs/sicherheit.md). --- ## Eigene App bauen Die App ist deine Webseite: im Look von Samy, mit samy.js und Anmeldung per Session-Token. Eine App ist eine Webseite, die du selbst baust. Samy zeigt sie als Kachel im Tab **Apps** und öffnet sie unter dem Kopf der App, so breit wie der Bildschirm. Du hast alle Freiheit von HTML, und mit `samy.css` sieht deine Seite trotzdem aus wie Samy. ### Kanal oder App | | [Kanal](https://packlets.app/docs/senden.md) | App | |---|---|---| | So geht es | Dein Tool schickt Nachrichten, Samy zeigt sie untereinander. | Die ganze App ist deine Webseite. | | Du brauchst | nur HTTP-Aufrufe | einen Server, der die Seite über `https://` ausliefert | | Gut für | Meldungen: ein Server fällt aus, ein Deploy schlägt fehl, eine Bestellung kommt rein | Arbeit mit Listen und Daten: Rechnungen freigeben, Bestellungen ansehen, Tickets beantworten | | Aussehen | immer wie Samy | mit `samy.css` wie Samy, sonst frei | | Bedienung kommt an als | [Webhook-Ereignis](https://packlets.app/docs/ereignisse.md) | normale Anfrage deiner Seite an deinen Server | Fang mit einem Kanal an, wenn dein Tool sich vor allem melden soll. Auch dort gehen Knöpfe, Schalter, Diagramme und Tabellen, als [Elemente in Nachrichten](https://packlets.app/docs/nachrichten-elemente.md). Eine App lohnt sich, wenn du immer wieder etwas nachsehen oder abarbeiten willst. Ideen, was eine App sein kann: - **Rechnungen freigeben:** Jede offene Rechnung als Karte. Wisch nach rechts gibt sie frei, nach links lehnt sie ab. - **Server und Deploys:** Status deiner Dienste, letzte Deploys, Knöpfe für Neustart und Rollback. - **Freigaben:** Pull Requests oder Spesen ansehen und mit einem Tipp freigeben. - **Support-Postfach:** Neue Anfragen lesen und kurz antworten. - **Bestellungen:** Die Bestellungen deines kleinen Shops von heute, offen und versendet. Wer Shopify kennt: Es ist dasselbe Prinzip wie bei eingebetteten Shopify-Apps mit App Bridge und Session-Token. **Du brauchst:** einen Server mit `https`-Adresse, der deine Seite ausliefert. ### 1. Die Seite bauen Eine normale HTML-Seite. Zwei Dateien von Samy machen sie zur App: `samy.css` für das Aussehen und `samy.js` für die Verbindung zur App. ```html

Rechnung

–

``` Die Klassen wie `samy-row` und `samy-button` kommen aus `samy.css`. Alle stehen in der [Referenz](https://packlets.app/docs/api-seiten.md#samy-css). ### 2. Die Seite zur App machen Leg in Samy ein Packlet an und wähle **Eigene App bauen**. Dann schickt dein Tool einmal die Adresse deiner Seite: ```bash curl -X PUT https://api.packlets.app/v1/ui \ -H "Authorization: Bearer $SAMY_KEY" \ -H "Content-Type: application/json" \ -d '{"page": "https://dein-server.de/rechnungen"}' ``` Antwort: `{"ok": true, "page": "https://dein-server.de/rechnungen"}`. Samy zeigt **Verbunden!**, und die Kachel steht im Tab **Apps**. - Nur `https://`. Im Body steht nur `page`. - Beim ersten Öffnen zeigt Samy die Adresse deiner Seite und fragt nach, bevor es sie lädt. Wechselst du später auf eine andere Adresse (anderer Host oder Port), fragt Samy erneut. - Eine andere Adresse setzt du mit einem neuen `PUT`. - Einmal reicht. Was die Seite zeigt, bestimmt danach dein Server. ### 3. Mit der App sprechen: samy.js Über das Objekt `samy` nutzt deine Seite, was nur die App kann: | Aufruf | Was passiert | |---|---| | `samy.toast("Gespeichert")` | Kurze Meldung in der App. | | `samy.haptic("success")` | Vibration. | | `samy.open("https://…")` | Öffnet eine Adresse im Browser. | | `await samy.fetch(url, init)` | Wie `fetch()`, aber mit Anmeldung. Siehe nächster Schritt. | | `samy.ready()` | Seite ist fertig, Samy blendet den Platzhalter aus (mit `data-ready="manual"`). | | `samy.storage.set("ort", "Berlin")` | Speichert Text auf dem Gerät, getrennt nach Konto. | Samy hält geöffnete Seiten im Speicher. Kommt jemand zurück, ist deine Seite sofort da, ohne neu zu laden, und bekommt das Ereignis `samy:activated`. Samys Farben stehen als CSS-Variablen bereit (`--samy-bg`, `--samy-text`, `--samy-cta` …). Im normalen Browser gibt es die App nicht, und die Aufrufe schlagen fehl. Frag deshalb vorher `samy.inSamy` ab, wenn deine Seite auch dort laufen soll: ```html ``` Alle Aufrufe stehen in der [Referenz](https://packlets.app/docs/api-seiten.md#samy-js). ### 4. Wissen, wer die Seite öffnet Deine Seite ist öffentlich erreichbar. Zeigt sie private Daten, muss dein Server wissen, dass die Anfrage wirklich aus deiner App in Samy kommt. Ein eigenes Login brauchst du dafür nicht: Samy weiß schon, wer angemeldet ist, und bestätigt es deinem Server mit einem **Session-Token**. 1. Deine Seite ruft `samy.fetch()` auf. Das holt bei Samy ein frisches Token und schickt es als `Authorization: Bearer …` an deinen Server. 2. Dein Server prüft das Token. Stimmt es, weiß er, wer die Seite öffnet. Das Token ist ein JWT, 60 Sekunden gültig und signiert mit einem Geheimnis, das nur du und Samy kennen: dem SHA-256-Hash deines Schlüssels. Geprüft wird auf deinem Server: ```python import hashlib, os import jwt # pip install pyjwt SECRET = hashlib.sha256(os.environ["SAMY_KEY"].encode()).hexdigest() PACKLET_ID = os.environ["SAMY_PACKLET_ID"] def samy_user(request): token = request.headers.get("Authorization", "").removeprefix("Bearer ") claims = jwt.decode(token, SECRET, algorithms=["HS256"], audience=PACKLET_ID) return {"id": claims["sub"], "email": claims.get("email"), "name": claims.get("name")} ``` ```typescript import { createHash } from "node:crypto"; import { jwtVerify } from "jose"; // npm install jose const secret = new TextEncoder().encode(createHash("sha256").update(process.env.SAMY_KEY ?? "").digest("hex")); export async function samyUser(authorization = "") { const token = authorization.replace(/^Bearer /, ""); const { payload } = await jwtVerify(token, secret, { algorithms: ["HS256"], audience: process.env.SAMY_PACKLET_ID }); return { id: payload.sub, email: payload.email as string | undefined, name: payload.name as string | undefined }; } ``` Schlägt die Prüfung fehl, werfen beide Funktionen einen Fehler. Antworte dann mit `401`. - **`SAMY_PACKLET_ID`** ist die ID deines Packlets. Du bekommst sie in der Seite mit `(await samy.info()).packlet_id`, und sie steht in jedem Webhook-Ereignis als `packlet_id`. - **Kein Cookie, keine Sitzung.** `samy.fetch()` holt für jede Anfrage ein frisches Token. - **Ohne JWT-Bibliothek** kann dein Server das Token auch [von Samy prüfen lassen](https://packlets.app/docs/api-seiten.md#token-von-samy-pruefen-lassen). > **Achtung:** Der Schlüssel gehört auf deinen Server, nie in die Seite. Was im HTML oder JavaScript steht, kann jeder lesen. ### 5. iframes und fremde Inhalte Deine Seite selbst ist kein iframe: Samy zeigt sie in einem eigenen Browser in der App. Zwei Dinge solltest du trotzdem regeln. **Fremde Inhalte in deiner Seite.** Bettest du ein iframe von einer anderen Adresse ein (Video, Karte, Widget), kann es `samy` nicht benutzen. Samy gibt der Seite beim Laden einen geheimen Brücken-Schlüssel, den nur deine Seite sieht, und ignoriert jeden Aufruf ohne ihn. Was das iframe sonst tut, entscheidest du: ```html ``` - **`sandbox`** immer setzen und nur freigeben, was das iframe braucht. Ohne `allow-top-navigation` kann es deine Seite nicht wegschicken, ohne `allow-popups` keine Fenster öffnen. - **Fremde Skripte** (Werbung, Tracking) direkt in deiner Seite laufen mit allen Rechten deiner Seite, auch mit `samy.fetch()`. Bind nur Skripte ein, denen du vertraust. - **Inhalte von Nutzern** nie als HTML einsetzen (`innerHTML`), sondern als Text (`textContent`). **Deine Seite in fremden Seiten.** Deine Adresse ist öffentlich. Eine andere Webseite könnte sie in ein iframe laden und Nutzer zu Klicks verleiten. Ein Token bekommt sie dort nicht, weil es Samy dort nicht gibt. Verbiete das Einbetten trotzdem mit einem Header deines Servers: ```http Content-Security-Policy: frame-ancestors 'none' X-Frame-Options: DENY ``` Samy stört das nicht, denn die App bettet deine Seite nicht in ein iframe ein. ### Damit es sich wie eine App anfühlt - **Weißer Hintergrund**, kein eigener Rahmen und kein Schatten außen herum. - **Kein eigener Kopf.** Name und Avatar zeigt Samy schon. Was wichtig ist, gehört nach oben. - **Tippflächen mindestens 44 Pixel** hoch, Text mindestens 12 Pixel. - **Kein Hover, keine Pop-ups**, kein `alert()`. Für Meldungen gibt es `samy.toast()`. - **Links** auf deiner eigenen Adresse bleiben in der App. Links zu anderen Adressen öffnen sich im Browser. - **Testen** kannst du im Browser in Handy-Breite, zum Beispiel mit 375 Pixeln in den Entwicklerwerkzeugen. ### Benachrichtigen Eine App kann auch Nachrichten schicken, mit demselben [`POST /v1/push`](https://packlets.app/docs/api-push.md) wie ein Kanal. Die Nachricht kommt als Push, und die Kachel zeigt einen Zähler. So meldet sich zum Beispiel die Rechnungs-App, wenn neue Rechnungen warten. In der App selbst siehst du nur deine Seite, keinen Verlauf. ### Ausprobieren Die beiden Seiten oben sind echte Apps: - **Wetter** zeigt, wie frei du gestalten kannst. Jedes neue Konto hat sie in Samy unter **Apps**. - **Konto** zeigt das Session-Token: Die Seite holt bei jeder Anfrage ein frisches Token, ihr Server prüft es und zeigt deinen Namen. Im Browser zeigt dieselbe Seite nur einen Hinweis, denn dort gibt es kein Token. ### Checkliste - Adresse der Seite mit `PUT /v1/ui` und `{"page": "https://…"}` geschickt. - `` gesetzt. - `samy.css` und `samy.js` eingebunden. - Anfragen an deinen Server laufen über `samy.fetch()`. - Dein Server prüft bei jeder Anfrage Signatur, Ablauf und `aud` des Tokens. - Der Schlüssel steht nur auf dem Server. - Fremde iframes mit `sandbox`, deine Seite mit `frame-ancestors 'none'` ausgeliefert. ### Wie es weitergeht - Alle Aufrufe von `samy.js`, das Token und `samy.css`: [Referenz](https://packlets.app/docs/api-seiten.md). - Feld, Antwort und Fehler von `PUT /v1/ui`: [PUT /v1/ui](https://packlets.app/docs/api-ui.md). --- ## API-Grundlagen Adresse, Anmeldung, alle Aufrufe, Rate-Limit und alle Grenzen auf einen Blick. Diese Seite gilt für alle Aufrufe. Die einzelnen Aufrufe mit ihren Feldern stehen auf den Seiten danach. ### Adresse ```text https://api.packlets.app ``` Alle Aufrufe laufen über `https://`. Anfragen und Antworten sind JSON in UTF-8. ### Anmeldung Jeder Aufruf trägt den Schlüssel deines Packlets im Header: ```http Authorization: Bearer Content-Type: application/json ``` Der Schlüssel bestimmt das Packlet und damit den Kanal. Eine Kanal-Angabe im Body gibt es nicht. Ausnahmen sind die Adressen für [Slack](https://packlets.app/docs/slack.md), [Discord](https://packlets.app/docs/discord.md) und [ntfy](https://packlets.app/docs/ntfy.md): Dort steht der Schlüssel in der Adresse, weil diese Tools keinen Header setzen können. Mehr zum Schlüssel steht unter [Schlüssel und Sicherheit](https://packlets.app/docs/sicherheit.md). ### Alle Aufrufe | Aufruf | Zweck | Kanal-Art | |---|---|---| | [`POST /v1/push`](https://packlets.app/docs/api-push.md) | Eine Nachricht schicken. | Kanal, App | | [`PUT /v1/ui`](https://packlets.app/docs/api-ui.md) | Die Seite einer App setzen. | nur App | | [`POST /v1/slack/`](https://packlets.app/docs/slack.md) | Eine Nachricht im Slack-Format schicken. Der Schlüssel steht in der Adresse. | Kanal, App | | [`POST /v1/discord/`](https://packlets.app/docs/discord.md) | Eine Nachricht im Discord-Format schicken. Der Schlüssel steht in der Adresse. | Kanal, App | | [`POST /`](https://packlets.app/docs/ntfy.md) | Eine Nachricht wie bei ntfy schicken (Text als Body, Angaben in Headern; auch `GET …/publish` und JSON an `/`). | Kanal, App | | [`POST /v1/session-token/verify`](https://packlets.app/docs/api-seiten.md#token-von-samy-pruefen-lassen) | Ein Session-Token prüfen lassen. Ohne Schlüssel. | nur App | In die andere Richtung schickt Samy [Webhook-Ereignisse](https://packlets.app/docs/ereignisse.md) an dein Tool. Mit dem Schlüssel kann dein Tool nur schicken. Nachrichten lesen, ändern oder löschen kann es nicht. ### Anfragen - Der Body ist JSON und höchstens 64 KiB groß. Größere Anfragen lehnt Samy mit `413` ab. - Felder, die Samy nicht kennt, werden ignoriert. - Leere Texte (`""`) und `null` bei optionalen Feldern zählen als nicht angegeben. - `POST /v1/push` nimmt auch Formulardaten an. Für einen schnellen Test reicht also `curl -d text=Hallo`. - Aus einer Webseite im Browser lässt sich die API nicht direkt rufen (kein CORS). Ruf sie von deinem Server aus. ### Antworten Erfolg ist immer Status `200` mit JSON. Jeder Fehler hat eine feste Kennung im Feld `error`: ```json { "error": "invalid_body", "message": "Ungültige Daten.", "details": [{ "path": "text", "message": "text darf nicht leer sein" }] } ``` Alle Kennungen stehen unter [Fehlercodes](https://packlets.app/docs/fehler.md). ### Rate-Limit Erlaubt sind **60 Aufrufe pro Minute** und Packlet, für jeden Aufruf einzeln gezählt. Ein neuer Schlüssel für dasselbe Packlet zählt weiter. Aufrufe ohne gültigen Schlüssel zählen pro IP-Adresse. Dazu gilt ein Limit pro Konto: **300 Aufrufe pro Minute** über alle deine Packlets zusammen. Beide Limits sind reiner Missbrauchsschutz. Jede Antwort nennt den Stand in diesen Headern: | Header | Inhalt | |---|---| | `x-ratelimit-limit` | Erlaubte Aufrufe pro Minute. | | `x-ratelimit-remaining` | Wie viele in dieser Minute noch übrig sind. | | `x-ratelimit-reset` | Sekunden, bis die Zählung neu beginnt. | | `retry-after` | Nur bei `429`: so viele Sekunden warten. | ### Grenzen | Was | Grenze | |---|---| | Anfrage | 64 KiB | | `text` einer Nachricht | 1 bis 10.000 Zeichen | | `title` einer Nachricht | 80 Zeichen | | `url`, `image`, `page` | 2.048 Zeichen | | Knöpfe pro Nachricht | 3 | | Elemente pro Nachricht | 12 | | `id` eines Elements | 1 bis 40 Zeichen: Buchstaben, Ziffern, `_`, `-` | | `id` eines Knopfs | 1 bis 64 Zeichen | | Deine Nachricht in einem Kanal mit Antworten | 4.000 Zeichen | | Aufrufe | 60 pro Minute und Packlet, 300 pro Minute und Konto | | Packlets pro Konto | 100 | | Aufbewahrung | Nachrichten 365 Tage, Protokoll 30 Tage | Die Grenzen der einzelnen Elemente stehen unter [Elemente](https://packlets.app/docs/elemente.md). --- ## POST /v1/push Eine Nachricht schicken: alle Felder, Antwort und Fehler. Schickt eine Nachricht in den Kanal des Packlets. Geht für jede Kanal-Art: Im Kanal ist es eine Meldung oder die Antwort deines Tools, bei einer App eine Benachrichtigung. Die Anleitung dazu: [Nachricht senden](https://packlets.app/docs/senden.md). ### Aufruf ```http POST https://api.packlets.app/v1/push Authorization: Bearer Content-Type: application/json ``` ### Felder | Feld | Typ | Pflicht | Bedeutung | |---|---|---|---| | `text` | Text, 1 bis 10.000 Zeichen | ja | Die Nachricht. Erlaubt ist einfache [Formatierung](https://packlets.app/docs/senden.md#formatierung). | | `title` | Text, bis 80 Zeichen | nein | Überschrift. | | `url` | `https`-Adresse, bis 2.048 Zeichen | nein | Link der Nachricht. | | `image` | `https`-Adresse, bis 2.048 Zeichen | nein | Bild über dem Text. | | `buttons` | Liste, bis 3 Einträge | nein | [Knöpfe](https://packlets.app/docs/knoepfe.md) unter der Nachricht. | | `elements` | Liste, bis 12 Einträge | nein | [Elemente](https://packlets.app/docs/elemente.md) in der Nachricht. | | `priority` | `normal`, `high` oder `silent` | nein | Standard `normal`. Siehe [Push, Ton und Ruhezeit](https://packlets.app/docs/push.md). | #### Felder eines Knopfs | Feld | Typ | Pflicht | Bedeutung | |---|---|---|---| | `id` | Text, 1 bis 64 Zeichen | ja | Deine Kennung, pro Nachricht eindeutig. Kommt im Webhook als `button_id` zurück. | | `label` | Text, 1 bis 40 Zeichen | ja | Beschriftung. | | `url` | `https`-Adresse | nein | Mit `url` öffnet der Knopf einen Link, ohne meldet er den Tipp an deinen Webhook. | ### Beispiel ```json { "title": "Deploy fehlgeschlagen", "text": "**shop-api** auf main: Tests rot in `checkout.test.ts`.", "url": "https://ci.example.com/runs/4711", "priority": "high", "buttons": [ { "id": "retry", "label": "Erneut starten" }, { "id": "log", "label": "Log öffnen", "url": "https://ci.example.com/runs/4711/log" } ] } ``` ### Antwort ```json { "message_id": "5b0c1a52-7c1e-4a43-9d0b-2f6f0c7d1e11" } ``` | Feld | Bedeutung | |---|---| | `message_id` | Kennung der Nachricht. Sie kommt in Webhook-Ereignissen zu dieser Nachricht wieder. | | `warnings` | Nur wenn Samy die Größe eines Elements angepasst hat: Liste mit je einem Satz pro Änderung. | Status `200` heißt: Die Nachricht ist gespeichert und steht im Kanal. Das gilt auch, wenn der Push nicht zugestellt wird, zum Beispiel in der Ruhezeit. Der erste erfolgreiche Aufruf verbindet das Packlet: Die App zeigt „Verbunden!“. ### Fehler | Status | `error` | Wann | |---|---|---| | 400 | `invalid_body` | Ein Feld fehlt oder passt nicht. `details` nennt Feld und Grund. | | 401 | `missing_key`, `invalid_key` | Schlüssel fehlt oder ist falsch. | | 429 | `rate_limited` | Mehr als 60 Aufrufe in einer Minute. | Was du jeweils tun kannst, steht unter [Fehlercodes](https://packlets.app/docs/fehler.md). --- ## PUT /v1/ui Die Seite einer App setzen: Feld, Antwort und Fehler. Mit `PUT /v1/ui` sagt dein Tool, welche Webseite eine App zeigt. Den Aufruf gibt es nur für Packlets der Art App. Die Anleitung dazu: [Eigene App bauen](https://packlets.app/docs/apps.md). ### PUT /v1/ui ```http PUT https://api.packlets.app/v1/ui Authorization: Bearer Content-Type: application/json ``` | Feld | Typ | Pflicht | Bedeutung | |---|---|---|---| | `page` | `https`-Adresse, bis 2.048 Zeichen | ja | Die Seite, die die App zeigt. | ```json { "page": "https://dein-server.de/rechnungen" } ``` Was ein `PUT` bewirkt: - Die App zeigt ab jetzt diese Seite. Eine andere Adresse setzt du mit einem neuen `PUT`. - Der erste erfolgreiche `PUT` verbindet das Packlet: Die App zeigt „Verbunden!“. - Wechselt die Adresse auf einen anderen Host oder Port, fragt Samy beim nächsten Öffnen nach, bevor es die Seite lädt. Einmal reicht. Was die Seite zeigt, bestimmt danach dein Server. #### Antwort ```json { "ok": true, "page": "https://dein-server.de/rechnungen" } ``` ### Fehler | Status | `error` | Wann | |---|---|---| | 400 | `invalid_body` | `page` fehlt, ist keine `https`-Adresse oder zu lang. `details` nennt Feld und Grund. | | 401 | `missing_key`, `invalid_key` | Schlüssel fehlt oder ist falsch. | | 409 | `wrong_kind` | Das Packlet ist ein Kanal, keine App. | | 429 | `rate_limited` | Mehr als 60 Aufrufe in einer Minute. | Was du jeweils tun kannst, steht unter [Fehlercodes](https://packlets.app/docs/fehler.md). --- ## Elemente Text, Button, Schalter, Eingabefeld, Diagramm und Tabelle in Nachrichten: Felder, Werte und Größen. Diese sechs Elemente hängst du an eine [Nachricht](https://packlets.app/docs/nachrichten-elemente.md), im Feld `elements` von [`POST /v1/push`](https://packlets.app/docs/api-push.md). Bis zu 12 pro Nachricht. ### Übersicht | `type` | Element | Wofür | Wert | Bedienbar | |---|---|---|---|---| | `text` | [Text](#text) | Überschrift, Absatz oder Hinweis | der Text | nein | | `button` | [Button](#button) | eine Aktion auslösen | – | ja, bei jedem Tipp | | `toggle` | [Schalter](#schalter) | etwas an- oder ausschalten | `true` oder `false` | ja, bei jedem Umschalten | | `input` | [Eingabefeld](#eingabefeld) | Text an dein Tool schicken | der Text | ja, beim Abschicken | | `chart` | [Diagramm](#diagramm) | Zahlen als Balken oder Linie | die Punkte | nein | | `table` | [Tabelle](#tabelle) | Daten in Zeilen und Spalten | die Zeilen | nein | **Wert** ist das, was dein Tool beim Senden mitgibt. Bei Schalter und Eingabefeld ändert er sich, wenn du sie in der App bedienst; Samy speichert ihn. Dein Tool kann Werte in einer gesendeten Nachricht nicht mehr ändern. **Bedienbar** heißt: Jede Bedienung geht als [Ereignis `ui`](https://packlets.app/docs/ereignisse.md#element-bedient-ui) mit der `message_id` an deinen Webhook. ### Gemeinsame Felder | Feld | Typ | Pflicht | Bedeutung | |---|---|---|---| | `type` | einer der sechs Typen | ja | Art des Elements. | | `id` | 1 bis 40 Zeichen: Buchstaben, Ziffern, `_`, `-` | ja | Deine Kennung, pro Nachricht eindeutig. Kommt im Webhook als `element_id` zurück. | | `width` | ganze Zahl 1 bis 6 | nein | Breite in Spalten. Standard 6, die ganze Breite. | | `height` | Zahl in Punkten | nein | Mindesthöhe. Nicht bei `text`. | Wie Breite und Höhe zusammenspielen, steht unter [Layout](https://packlets.app/docs/layout.md). ### Text | Feld | Typ | Pflicht | Bedeutung | |---|---|---|---| | `text` | Text, 1 bis 2.000 Zeichen | ja | Der Inhalt. | | `style` | `title`, `body` oder `caption` | nein | Überschrift, Absatz (Standard) oder kleiner grauer Hinweis. | - **Wert:** der Text. - **Größe:** mindestens 2 Spalten breit. Die Höhe richtet sich nach dem Inhalt, `height` gibt es nicht. - Bei `body` gilt dieselbe [Formatierung](https://packlets.app/docs/senden.md#formatierung) wie im Text der Nachricht. ```json { "type": "text", "id": "stand", "text": "Zuletzt geprüft um 9:41", "style": "caption" } ``` ### Button | Feld | Typ | Pflicht | Bedeutung | |---|---|---|---| | `label` | Text, 1 bis 40 Zeichen | ja | Beschriftung. | | `style` | `primary` oder `secondary` | nein | Dunkel (Standard) oder hell. | - **Wert:** keiner. Ein Tipp schickt das Ereignis `ui` mit `value: null`. - **Größe:** mindestens 2 Spalten breit, Höhe 44 bis 240. - Ein Button lässt sich beliebig oft tippen. Das unterscheidet ihn vom [Knopf](https://packlets.app/docs/knoepfe.md) im Feld `buttons`. ```json { "type": "button", "id": "restart", "label": "Neu starten", "width": 3 } ``` ### Schalter | Feld | Typ | Pflicht | Bedeutung | |---|---|---|---| | `label` | Text, 1 bis 60 Zeichen | ja | Beschriftung. | | `description` | Text, bis 160 Zeichen | nein | Erklärung unter der Beschriftung. | | `value` | `true` oder `false` | nein | Startwert. Standard `false`. | - **Wert:** `true` oder `false`. Jedes Umschalten schickt das Ereignis `ui` mit dem neuen Wert. - **Größe:** mindestens 3 Spalten breit, Höhe 64 bis 240. ```json { "type": "toggle", "id": "wartung", "label": "Wartungsseite zeigen", "value": true } ``` ### Eingabefeld | Feld | Typ | Pflicht | Bedeutung | |---|---|---|---| | `label` | Text, 1 bis 60 Zeichen | ja | Beschriftung. | | `placeholder` | Text, bis 80 Zeichen | nein | Grauer Beispieltext im leeren Feld. | | `button` | Text, 1 bis 30 Zeichen | nein | Beschriftung des Absende-Knopfs. Standard „Senden“. | | `value` | Text, bis 500 Zeichen | nein | Startwert. Standard leer. | - **Wert:** der Text, bis 500 Zeichen. Das Ereignis `ui` kommt erst beim Abschicken, nicht bei jedem Buchstaben. - **Größe:** mindestens 3 Spalten breit, Höhe 160 bis 400. ```json { "type": "input", "id": "antwort", "label": "Antwort an Lena", "placeholder": "z. B. Geht heute raus", "button": "Antworten" } ``` ### Diagramm | Feld | Typ | Pflicht | Bedeutung | |---|---|---|---| | `label` | Text, 1 bis 60 Zeichen | ja | Titel der Karte. | | `chart` | `bar` oder `line` | nein | Balken (Standard) oder Linie. | | `unit` | Text, bis 8 Zeichen | nein | Einheit hinter dem Wert oben rechts. | | `points` | Liste, bis 31 Punkte | nein | Je Punkt `{"label": "Mo", "value": 3.5}`. `label` bis 12 Zeichen, `value` eine Zahl. Standard leer. | | `highlight` | ganze Zahl ab 0 | nein | Welcher Punkt hervorgehoben ist, gezählt ab 0. Standard: der letzte. | - **Wert:** die Punkte. - **Größe:** mindestens 3 Spalten breit, Höhe 120 bis 480. - Bei vielen Punkten zeigt die App nur jede zweite oder dritte Beschriftung. ```json { "type": "chart", "id": "umsatz", "label": "Umsatz", "unit": "€", "points": [{ "label": "Mo", "value": 860 }, { "label": "Di", "value": 1204 }] } ``` ### Tabelle | Feld | Typ | Pflicht | Bedeutung | |---|---|---|---| | `columns` | Liste, 1 bis 6 Texte | ja | Spaltenköpfe, je bis 30 Zeichen. | | `rows` | Liste, bis 50 Zeilen | nein | Jede Zeile ist eine Liste von Zellen: Text (bis 80 Zeichen) oder Zahl. Höchstens so viele Zellen wie Spalten. Standard leer. | | `label` | Text, bis 60 Zeichen | nein | Titel der Karte. | - **Wert:** die Zeilen. - **Größe:** mindestens 3 Spalten breit, Höhe 80 bis 800. - Eine leere Tabelle zeigt „Noch keine Einträge“. Breite Tabellen lassen sich seitlich schieben. ```json { "type": "table", "id": "bestellungen", "label": "Bestellungen", "columns": ["Nr.", "Kunde", "€"], "rows": [["1047", "Jonas M.", 89.9], ["1048", "Aylin S.", 34.5]] } ``` ### Unbekannte Typen Einen `type`, den es nicht gibt, lehnt Samy mit `400 invalid_body` ab. Typen für TypeScript zum Kopieren stehen unter [Fertiger Code](https://packlets.app/docs/beispiele.md#typen-fuer-typescript). --- ## Webhook-Ereignisse Was Samy an deine Webhook-URL schickt: Ereignisse, Header, Signatur und Wiederholungen. Tust du etwas in der App, schickt Samy ein Ereignis an die Webhook-URL deines Packlets. Diese Seite ist zum Nachschlagen. Wie du einen Empfänger baust, steht unter [Webhook einrichten](https://packlets.app/docs/webhook.md). ### Übersicht | `type` | `X-Samy-Event` | Wann | |---|---|---| | [`button`](#knopf-getippt-button) | `button.pressed` | Du tippst einen Aktions-[Knopf](https://packlets.app/docs/knoepfe.md) unter einer Nachricht. | | [`ui`](#element-bedient-ui) | `ui.changed` | Du bedienst ein [Element](https://packlets.app/docs/elemente.md) in einer Nachricht: Button, Schalter oder Eingabefeld. | | [`message`](#chat-nachricht-message) | `message.created` | Du schreibst in einem [Kanal mit Antworten](https://packlets.app/docs/chat.md). | Jedes Ereignis ist ein `POST` mit JSON-Body. `type`, `packlet_id`, `event_id` und `timestamp` sind immer dabei. Alle Zeitpunkte sind ISO 8601 in UTC, nur `timestamp` ist eine Zahl. | Feld | Bedeutung | |---|---| | `event_id` | Kennung des Ereignisses. Wiederholt Samy die Zustellung, bleibt sie gleich. | | `timestamp` | Zeitpunkt der Zustellung in Sekunden seit 1970 (Unix-Zeit). | Beide stehen im Body und sind damit von der [Signatur](#signatur) gedeckt. Damit erkennst du [wiederholte und alte Anfragen](#schutz-vor-wiederholung). ### Knopf getippt: button ```json { "type": "button", "packlet_id": "9d2f7c10-3a5b-4c8e-9f21-6b7d8e9f0a12", "message_id": "5b0c1a52-7c1e-4a43-9d0b-2f6f0c7d1e11", "button_id": "done", "pressed_at": "2026-11-09T18:00:00.000Z" } ``` | Feld | Bedeutung | |---|---| | `message_id` | Die Nachricht, zu der der Knopf gehört. Derselbe Wert wie in der Antwort von `POST /v1/push`. | | `button_id` | Die `id`, die du dem Knopf gegeben hast. | | `pressed_at` | Zeitpunkt des Tipps. | Pro Nachricht kommt dieses Ereignis höchstens einmal: Nach dem ersten Tipp sind die Aktions-Knöpfe der Nachricht erledigt. Link-Knöpfe (mit `url`) lösen kein Ereignis aus. ### Element bedient: ui ```json { "type": "ui", "packlet_id": "9d2f7c10-3a5b-4c8e-9f21-6b7d8e9f0a12", "message_id": "5b0c1a52-7c1e-4a43-9d0b-2f6f0c7d1e11", "element_id": "rollback", "value": null, "changed_at": "2026-11-10T18:00:00.000Z" } ``` | Feld | Bedeutung | |---|---| | `message_id` | Die Nachricht, zu der das Element gehört. Derselbe Wert wie in der Antwort von `POST /v1/push`. | | `element_id` | Die `id` des Elements. | | `value` | Der neue Wert: `true` oder `false` beim Schalter, der Text beim Eingabefeld, `null` beim Button. | | `changed_at` | Zeitpunkt der Bedienung. | Den neuen Wert hat Samy schon gespeichert. Dein Tool muss ihn nicht zurückschicken. ### Chat-Nachricht: message ```json { "type": "message", "packlet_id": "9d2f7c10-3a5b-4c8e-9f21-6b7d8e9f0a12", "message_id": "c41e8a77-0d1f-4e6a-b2c3-91a4d5e6f708", "text": "Läuft der Shop wieder?", "sent_at": "2026-11-10T08:15:00.000Z" } ``` | Feld | Bedeutung | |---|---| | `message_id` | Deine Nachricht. | | `text` | Was du geschrieben hast, 1 bis 4.000 Zeichen, ohne Formatierung. | | `sent_at` | Zeitpunkt. | ### Header | Header | Wert | |---|---| | `Content-Type` | `application/json` | | `X-Samy-Event` | `button.pressed`, `ui.changed` oder `message.created` | | `X-Samy-Signature` | `sha256=` und dahinter die Signatur als Hex-Text | | `X-Samy-Event-Id` | Dasselbe wie `event_id` im Body | | `X-Samy-Timestamp` | Dasselbe wie `timestamp` im Body | | `User-Agent` | `Samy-Webhook/1.0` | ### Signatur ```text X-Samy-Signature = "sha256=" + hex( HMAC-SHA256( Geheimnis, Body ) ) Geheimnis = hex( SHA-256( SAMY_KEY ) ) ``` - **Body** sind die rohen Bytes der Anfrage, so wie sie ankommen. Nicht das neu erzeugte JSON. - **Geheimnis** ist der SHA-256-Hash deines Schlüssels als Hex-Text in Kleinbuchstaben, 64 Zeichen. Dieser Text ist der HMAC-Schlüssel, nicht die 32 Bytes dahinter. - Erzeugst du in der App einen neuen Schlüssel, ändert sich auch das Geheimnis. Fertiger Code dazu steht unter [Webhook einrichten](https://packlets.app/docs/webhook.md#4-signatur-pruefen). ### Schutz vor Wiederholung Eine gültige Anfrage bleibt gültig, auch wenn sie jemand mitschneidet und später noch einmal schickt. Löst dein Tool etwas aus, das nicht doppelt passieren darf, prüf nach der Signatur zwei Dinge: - **`timestamp`** liegt höchstens 5 Minuten zurück. Ältere Anfragen lehnst du ab. - **`event_id`** hast du in diesen 5 Minuten noch nicht gesehen. Sonst antwortest du mit `200` und tust nichts. Lies beide Werte aus dem Body, nicht aus den Headern: Nur der Body ist signiert. ### Zustellung | Regel | Wert | |---|---| | Zeitlimit | 10 Sekunden bis zu deiner Antwort | | Zeitbudget | 30 Sekunden Antwortzeit pro Minute und Konto. Ist es verbraucht, verschiebt Samy weitere Ereignisse um einige Sekunden. Antworte deshalb sofort und erledige die Arbeit danach. | | Erfolg | jeder Status `2xx`. Der Inhalt deiner Antwort wird nicht gelesen. | | Wird wiederholt | keine Verbindung, Zeitüberschreitung, `429`, `5xx` | | Wird nicht wiederholt | alle anderen Antworten, auch `3xx`. Weiterleitungen folgt Samy nicht. | | Wiederholungen | bis zu 5, also höchstens 6 Versuche | | Abstand | erst etwa 10 bis 20 Sekunden, dann jedes Mal doppelt so lang. Nach rund zehn Minuten gibt Samy auf. | - **Mindestens einmal:** Wegen der Wiederholungen kann dasselbe Ereignis mehrfach ankommen. Es trägt dann dieselbe `event_id`. Wie du Doppelte erkennst, steht unter [Webhook einrichten](https://packlets.app/docs/webhook.md#doppelte-ereignisse). - **Kein Nachholen:** Hat das Packlet keine Webhook-URL, wird nichts gesendet und später auch nichts nachgeliefert. - Jeden Versuch zeigt die App im Protokoll des Packlets, mit dem Status deiner Antwort. ### Die Webhook-URL | Regel | | |---|---| | Protokoll | nur `https://` | | Länge | bis 2.048 Zeichen | | Zugangsdaten in der Adresse | nicht erlaubt (`https://name:passwort@…`) | | Ziel | öffentlich erreichbar. Private Adressen wie `localhost`, `10.…`, `192.168.…` lehnt Samy ab. | --- ## samy.js, samy.css, Session-Token Alles für die Seite deiner App: die Brücke, das Stylesheet und das Token. Zum Nachschlagen für die Seite deiner [App](https://packlets.app/docs/apps.md): die Brücke `samy.js`, das Session-Token und das Stylesheet `samy.css`. ### samy.js ```html ``` Danach gibt es das Objekt `samy`: | Aufruf | Ergebnis | |---|---| | `samy.inSamy` | `true`, wenn die Seite in der Samy-App läuft, sonst `false`. Kein Aufruf, sondern ein Wert. | | `samy.launch` | [Startdaten](#startdaten), sofort beim Laden da: `{ v, packlet_id, platform, theme }`. Kein Aufruf, sondern ein Wert. | | `samy.theme` | Samys Farben (siehe [Farben](#farben)). Kein Aufruf, sondern ein Wert. | | `samy.ready()` | Meldet, dass die Seite fertig ist. Samy blendet dann den Platzhalter aus. Nur nötig mit `data-ready="manual"`. | | `await samy.token()` | Das [Session-Token](#session-token) als Text. Direkt nach dem Laden kommt es ohne Wartezeit aus den Startdaten. | | `await samy.fetch(url, init)` | Wie `fetch()`, setzt aber `Authorization: Bearer ` mit einem frischen Token. Gibt die normale Antwort zurück. | | `await samy.info()` | `{ app: "samy", version: 2, packlet_id: "…", platform: "iOS", theme: {…} }`. `platform` ist `iOS` oder `android`. | | `samy.toast("Gespeichert")` | Kurze Meldung in der App. Der Text wird nach 140 Zeichen abgeschnitten. | | `samy.open("https://…")` | Öffnet die Adresse im Browser des Handys. Nur `http(s)`. | | `samy.haptic("success")` | Vibration: `tap` (Standard), `selection`, `success` oder `error`. | | `await samy.storage.get(key)` | Gespeicherter Text oder `null`. Siehe [Speicher](#speicher). | | `await samy.storage.set(key, text)` | Speichert Text (höchstens 16 KB). | | `await samy.storage.remove(key)` | Löscht einen Eintrag. | | `await samy.storage.keys()` | Alle Schlüssel dieser App. | Alle Aufrufe außer `samy.inSamy` geben ein Promise zurück. - **Außerhalb von Samy** (zum Beispiel im Browser) schlagen die Aufrufe sofort fehl. Frag deshalb zuerst `samy.inSamy` ab. - **Zeitlimit:** Antwortet die App nicht innerhalb von 15 Sekunden, schlägt der Aufruf fehl. - **Nur deine Adresse:** Antworten bekommen nur Seiten mit derselben Herkunft (`https`, Host und Port) wie die Adresse, die du als `page` gesetzt hast. - **Nur die Hauptseite:** Jeder Aufruf trägt einen geheimen Brücken-Schlüssel aus den [Startdaten](#startdaten). Aufrufe aus iframes fremder Adressen haben ihn nicht, Samy ignoriert sie. Mehr unter [iframes und fremde Inhalte](https://packlets.app/docs/seiten.md#5-iframes-und-fremde-inhalte). - Läuft die Seite in Samy, trägt `` die Klasse `in-samy`. Ist das Skript geladen, feuert auf `window` das Ereignis `samy:ready`. - **Wieder geöffnet:** Samy hält die zuletzt geöffneten Seiten im Speicher (bis zu vier). Kommt jemand zurück, lädt die Seite nicht neu. Stattdessen feuert auf `window` das Ereignis `samy:activated`. Das ist der richtige Moment, veraltete Daten aufzufrischen. Der Knopf oben rechts in der App lädt die Seite bei Bedarf ganz neu. #### Laden und Platzhalter Bis deine Seite fertig ist, zeigt Samy Avatar und Name der App. Normalerweise verschwindet der Platzhalter, sobald die Seite geladen ist. Lädt deine Seite danach noch Daten nach, bindest du das Skript mit `data-ready="manual"` ein und rufst selbst `samy.ready()`, wenn alles steht. Kommt nach sechs Sekunden kein `ready()`, blendet Samy den Platzhalter trotzdem aus. ```html ``` #### Startdaten Samy hängt beim Laden Startdaten an die Adresse, hinter `#samy=` (Base64url-kodiertes JSON). Der Teil hinter `#` geht nie an deinen Server. `samy.js` liest ihn sofort aus, stellt ihn als `samy.launch` bereit und entfernt ihn wieder aus der Adresse. Das Session-Token aus den Startdaten liefert `samy.token()` in den ersten 45 Sekunden ohne Umweg über die App. So weiß deine Seite schon beim ersten Aufruf deines Servers, wer sie öffnet (wie `initData` bei Telegram). Den Brücken-Schlüssel aus den Startdaten legt `samy.js` im `sessionStorage` deiner Adresse ab. So funktioniert `samy` auch auf weiteren Seiten, zu denen du innerhalb deiner Adresse verlinkst. Du musst dafür nichts tun. #### Farben `samy.theme` enthält Samys Farben: `bg`, `surface`, `text`, `text_secondary`, `text_tertiary`, `field`, `cta`, `on_cta`, `danger` und `scheme` (`light`). `samy.js` setzt sie zusätzlich als CSS-Variablen auf ``, zum Beispiel `--samy-bg`, `--samy-text`, `--samy-cta` oder `--samy-text-secondary`. Damit passt deine Seite ohne eigene Farbwerte zur App. #### Abstände In Samy füllt deine Seite die ganze Fläche unter Samys schmaler Kopfzeile bis zum unteren Rand (Vollbild). Die Kopfzeile bleibt immer gleich und verdeckt nichts. Unten setzt Samy die CSS-Variable `--samy-inset-bottom` (Home-Leiste des iPhones) und gibt `` diesen Abstand als Innenabstand; `--samy-inset-top` gibt es weiter, ist aber `0`. Normalerweise musst du nichts tun. - Willst du die volle Höhe ohne Scrollen füllen, rechne die Abstände heraus: `min-height: calc(100vh - var(--samy-inset-top) - var(--samy-inset-bottom))`. - Willst du alles selbst regeln, schreib `` und nutz die Variablen an passender Stelle. #### Speicher `samy.storage` speichert kleine Texte auf dem Gerät, getrennt nach Konto und App. Teilen sich zwei Konten ein Handy, sieht keins die Einträge des anderen. Das unterscheidet ihn vom normalen `localStorage` der Seite. Bis zu 100 Einträge je App, je höchstens 16 KB. Für Objekte nimm `JSON.stringify` und `JSON.parse`. Meldet sich jemand ab, bleiben die Einträge auf dem Gerät. Löscht er das Konto, sind sie wertlos, weil niemand sonst sie lesen kann. ### Session-Token Ein JWT, das sagt, wer die Seite gerade in Samy öffnet. | | | |---|---| | Algorithmus | `HS256` | | Geheimnis | SHA-256-Hash deines Schlüssels als Hex-Text in Kleinbuchstaben. Dasselbe wie bei der [Webhook-Signatur](https://packlets.app/docs/ereignisse.md#signatur). | | Gültigkeit | 60 Sekunden | | Feld | Inhalt | |---|---| | `iss` | `https://api.packlets.app` | | `aud` | ID deines Packlets. Dieselbe wie `packlet_id` in jedem Webhook-Ereignis. | | `sub` | ID des Nutzers in Samy. Bleibt für denselben Nutzer immer gleich. | | `email` | E-Mail-Adresse des Nutzers. | | `name` | Name des Nutzers, kann fehlen. | | `iat`, `exp` | Ausgestellt und gültig bis, als Unix-Zeit. | | `jti` | Eindeutige Kennung dieses Tokens. | Prüf auf deinem Server immer drei Dinge: die Signatur, `exp` und dass `aud` dein Packlet ist. Erzeugst du in der App einen neuen Schlüssel, sind alle Tokens sofort ungültig. ### Token von Samy prüfen lassen Ohne eigene JWT-Bibliothek fragt dein Server Samy. Dieser Aufruf braucht keinen Schlüssel. ```http POST https://api.packlets.app/v1/session-token/verify Content-Type: application/json ``` | Feld | Typ | Pflicht | Bedeutung | |---|---|---|---| | `token` | Text | ja | Das Token aus dem Header `Authorization` der Anfrage an deinen Server. | Antwort bei einem gültigen Token: ```json { "valid": true, "packlet_id": "9d2f7c10-3a5b-4c8e-9f21-6b7d8e9f0a12", "page_url": "https://deine-seite.example/app", "user": { "id": "4f1c…", "email": "du@example.com", "name": "Du" }, "expires_at": "2026-11-10T08:16:00.000Z" } ``` Sonst Status `401`: ```json { "valid": false, "error": "invalid_token", "message": "Das Token ist ungültig oder abgelaufen." } ``` > **Achtung:** Vergleich `packlet_id` mit der ID deines Packlets. Sonst nimmt dein Server auch Tokens an, die für ein fremdes Packlet ausgestellt wurden. Bedient dein Server viele Packlets (zum Beispiel eine App für alle Nutzer), prüf stattdessen `page_url`: Das ist die Seite, die das Packlet gerade zeigt. Für den Aufruf gilt das [Rate-Limit](https://packlets.app/docs/api.md#rate-limit), gezählt je Adresse deines Servers. ### samy.css ```html ``` Das Stylesheet bringt die Schrift Lato, die Farben der App und diese Bausteine: | Klasse | Wofür | |---|---| | `samy` | Rahmen der Seite: 16 Pixel Innenabstand, Bausteine untereinander mit 12 Pixel Abstand, mindestens so hoch wie der Bildschirm. | | `samy-title`, `samy-big` | Überschrift (20 px) und große Zahl (44 px). | | `samy-sub`, `samy-label`, `samy-note` | Grauer Nebentext, Abschnitts-Label in Großbuchstaben, kleiner Hinweis. | | `samy-row`, `samy-spread`, `samy-grow`, `samy-push` | Nebeneinander; an die Ränder verteilen; Platz füllen; nach unten schieben. | | `samy-button` | Knopf wie in der App, 44 px hoch. Dazu `is-secondary` (hell) und `is-small` (32 px). | | `samy-tabs` | Umschalter zwischen Ansichten. Der aktive Tab trägt `aria-selected="true"`. | | `samy-list` | Liste mit feinen Trennlinien, Zeilen mindestens 44 px hoch. | | `samy-table` | Tabelle. Zahlenspalten mit `is-num` rechtsbündig. | | `samy-field` | Eingabefeld. | | `samy-chip`, `samy-dot` | Kleines Kennzeichen; Status-Punkt grün, mit `is-warn` rot. | | `samy-bars` | Kleines Balkendiagramm aus ``. Der aktive Balken trägt `is-active`. | Farben und Maße als Variablen: | Variable | Bedeutung | |---|---| | `--samy-bg` | Hintergrund | | `--samy-text`, `--samy-text-2`, `--samy-text-3` | Haupttext, Nebentext, noch hellerer Text | | `--samy-icon` | Symbole | | `--samy-cta`, `--samy-on-cta` | Dunkelblau der Knöpfe und die Schrift darauf | | `--samy-field` | Hellgrau für Felder und helle Knöpfe | | `--samy-selected`, `--samy-bar` | Ausgewählter Tab, Balken im Diagramm | | `--samy-divider` | Trennlinien | | `--samy-ok`, `--samy-warn` | Grün und Rot für Status | | `--samy-radius` | Eckenradius | Text lässt sich auf der Seite nicht markieren (`user-select: none`), wie in einer App. Eine fertige Beispielseite liegt unter [/embed/beispiel.html](https://packlets.app/embed/beispiel.html). --- ## Fehlercodes Alle Fehler aller Aufrufe an einer Stelle, mit dem, was du tun kannst. Alle Fehler von `POST /v1/push`, `PUT /v1/ui` und `POST /v1/session-token/verify`. ### Aufbau eines Fehlers ```json { "error": "invalid_body", "message": "Ungültige Daten.", "details": [{ "path": "text", "message": "text darf nicht leer sein" }] } ``` | Feld | Bedeutung | |---|---| | `error` | Feste Kennung. Darauf prüft dein Code. | | `message` | Lesbarer Satz auf Deutsch. Der Wortlaut kann sich ändern. | | `details` | Nur bei `invalid_body`: je Eintrag das Feld (`path`) und der Grund. | ### Alle Kennungen | Status | `error` | Bedeutung | Was tun | |---|---|---|---| | 400 | `invalid_body` | Ein Feld fehlt, ist zu lang oder hat den falschen Typ. | `details` lesen und den Body korrigieren. Nicht unverändert wiederholen. | | 401 | `missing_key` | Der Header `Authorization` fehlt oder beginnt nicht mit `Bearer`. | Header `Authorization: Bearer ` setzen. | | 401 | `invalid_key` | Der Schlüssel ist falsch, wurde neu erzeugt, oder das Packlet wurde entfernt. | Aktuellen Schlüssel eintragen. Ist er verloren, in der App einen neuen erzeugen. | | 401 | `invalid_token` | Nur `session-token/verify`: Das Token ist abgelaufen, gefälscht oder gehört zu keinem Packlet. | Die Seite holt mit `samy.token()` ein frisches Token. | | 409 | `wrong_kind` | `PUT /v1/ui` bei einem Kanal. Nur eine App hat eine Seite. | Ein neues Packlet mit **Eigene App bauen** verbinden. Die Art lässt sich nicht ändern. | | 429 | `rate_limited` | Mehr als 60 Aufrufe in einer Minute. | So lange warten, wie der Header `retry-after` sagt, dann erneut senden. | ### Fehler ohne Kennung von Samy Ist schon die Anfrage selbst kaputt, antwortet der Server mit einem allgemeinen Fehler. Das Feld `code` sagt, was es war: | Status | `code` | Bedeutung | |---|---|---| | 400 | `FST_ERR_CTP_INVALID_JSON_BODY` | Der Body ist kein gültiges JSON. Oft ein fehlendes Anführungszeichen oder ein Komma zu viel. | | 400 | `FST_ERR_CTP_EMPTY_JSON_BODY` | `Content-Type: application/json`, aber kein Body. | | 413 | `FST_ERR_CTP_BODY_TOO_LARGE` | Der Body ist größer als 64 KiB. | | 415 | `FST_ERR_CTP_INVALID_MEDIA_TYPE` | Der `Content-Type` ist weder JSON noch ein Formular. | ### Wiederholen oder nicht | Antwort | Wiederholen? | |---|---| | `429`, `5xx`, keine Verbindung, Zeitüberschreitung | Ja, nach einer Pause. | | `400`, `401`, `409`, `413`, `415` | Nein. Erst die Ursache beheben. | Kommt trotz `200` nichts an, hilft die [Fehlersuche](https://packlets.app/docs/fehlersuche.md). --- ## Fehlersuche Wenn etwas nicht ankommt: die häufigsten Ursachen und wo du nachsiehst. Wenn etwas nicht ankommt, liegt es fast immer an einer von wenigen Ursachen. Such unten die Überschrift, die zu dem passt, was du siehst. ### Zuerst: das Protokoll Jedes Packlet hat ein Protokoll. Tippe im Kanal oben auf Avatar oder Namen und dann auf **Protokoll**. Dort stehen die letzten 50 Aufrufe mit Status: - Aufrufe deines Tools, die angekommen sind: erfolgreiche und solche mit `400`. - Jeder Versuch von Samy, deinen Webhook zu erreichen, mit dem Status deiner Antwort. > **Hinweis:** Aufrufe mit `401`, `409` und `429` stehen nicht im Protokoll. Ist der Schlüssel falsch, weiß Samy nicht, zu welchem Packlet der Aufruf gehört. Bleibt das Protokoll leer, sieh dir also die Antwort an, die dein Tool bekommen hat. ### Der Aufruf schlägt fehl Die Antwort sagt, warum. Lass dein Tool Status und Body ausgeben: | Du bekommst | Ursache | Lösung | |---|---|---| | `401` `missing_key` | Der Header fehlt. | `Authorization: Bearer ` setzen. Auf das Wort `Bearer` und das Leerzeichen achten. | | `401` `invalid_key` | Der Schlüssel stimmt nicht. | Ist die Umgebungsvariable gesetzt und vollständig (`samy_` und 32 Zeichen)? Wurde in der App ein neuer Schlüssel erzeugt? Dann gilt nur noch der neue. | | `400` `invalid_body` | Ein Feld passt nicht. | `details` nennt das Feld. Häufig: `text` fehlt oder ist leer, ein Text ist zu lang, eine `id` kommt doppelt vor. | | `400` ohne `error` von Samy | Der Body ist kein gültiges JSON. | Anführungszeichen und Kommas prüfen. In der Shell den Body in einfache Anführungszeichen setzen. | | `409` `wrong_kind` | `PUT /v1/ui` bei einem Kanal. | Ein Packlet mit **Eigene App bauen** verbinden. | | `429` `rate_limited` | Mehr als 60 Aufrufe pro Minute. | Warten, wie `retry-after` sagt. Schickt eine Schleife zu oft? | | keine Antwort | Dein Tool erreicht Samy nicht. | Adresse prüfen: `https://api.packlets.app`. Hat der Rechner Internet? | Alle Kennungen stehen unter [Fehlercodes](https://packlets.app/docs/fehler.md). ### Die Nachricht ist da, aber kein Push Der Aufruf antwortet mit `200`, die Nachricht steht im Kanal, nur auf dem Sperrbildschirm erscheint nichts. Dann wurde der Push absichtlich nicht geschickt, oder das Handy zeigt ihn nicht: 1. **Ist Push für das Packlet an?** In den Einstellungen des Packlets: Push an, Kanal nicht stumm. 2. **Ist gerade Ruhezeit?** Dann kommt nur `priority: "high"` durch. 3. **Sind Mitteilungen für Samy erlaubt?** In den Einstellungen des Handys unter Mitteilungen. 4. **Ist ein Fokus oder „Nicht stören“ aktiv?** Das Handy hält Pushes dann zurück. 5. **Ist die App gerade offen?** Dann erscheint die Nachricht direkt im Kanal. Ein ausgefallener Push wird nicht nachgeholt. Mehr dazu unter [Push, Ton und Ruhezeit](https://packlets.app/docs/push.md). ### Der Webhook kommt nicht an Du tippst einen Knopf oder bedienst ein Element, aber dein Tool merkt nichts. Sieh ins Protokoll: | Im Protokoll | Ursache | Lösung | |---|---|---| | kein Webhook-Eintrag | Das Packlet hat keine Webhook-URL, oder der Knopf hat eine `url` und ist damit ein Link-Knopf. | Webhook-URL in den Einstellungen eintragen. Beim Knopf `url` weglassen. | | Status `0` | Samy erreicht die Adresse nicht: falsche Adresse, Server aus, Tunnel beendet, Antwort später als 10 Sekunden, oder die Adresse ist privat. | Adresse im Browser oder mit `curl` von außen prüfen. Schneller antworten. | | Status `401` oder `403` | Dein Tool lehnt ab, meist wegen der Signatur. | Siehe nächster Abschnitt. | | Status `404` oder `405` | Der Pfad stimmt nicht, oder dein Empfänger nimmt kein `POST` an. | Adresse mit dem Pfad deines Empfängers vergleichen, zum Beispiel `/samy`. | | Status `301`, `302`, `308` | Deine Adresse leitet weiter. Samy folgt Weiterleitungen nicht. | Die endgültige Adresse eintragen, oft mit `https://` oder mit `www`. | | Status `500` | Dein Tool ist beim Verarbeiten abgestürzt. | Ins Log deines Tools sehen. Samy versucht es noch mehrmals. | | Status `200` oder `204` | Der Webhook ist angekommen. | Der Fehler liegt danach, in deiner Logik. Gib das Ereignis aus. | Lässt sich die Adresse gar nicht erst eintragen, ist sie nicht erlaubt: Sie muss mit `https://` beginnen, öffentlich erreichbar sein und darf keine Zugangsdaten enthalten. Ereignisse aus der Zeit, in der der Webhook nicht erreichbar war, kommen nicht nachträglich. Nur die letzten Versuche, etwa zehn Minuten lang. ### Die Signatur stimmt nicht Dein Empfänger bekommt die Anfrage, aber die Prüfung schlägt fehl. Die üblichen Gründe: - **Der Body wurde verändert.** Rechne über die rohen Bytes, nicht über das JSON, das dein Framework daraus gemacht hat. In Flask ist das `request.get_data()`, in Express brauchst du `express.raw()` statt `express.json()`. - **Das Geheimnis ist falsch gebildet.** Es ist der SHA-256-Hash des Schlüssels als Hex-Text, und dieser Text ist der HMAC-Schlüssel. Nicht der Schlüssel selbst und nicht die rohen Hash-Bytes. - **Der Schlüssel ist alt.** Nach „Neuen Schlüssel erzeugen“ gilt auch für die Signatur der neue. - **Das `sha256=` fehlt** im Vergleich. Der Header beginnt damit. Der fertige Code steht unter [Webhook einrichten](https://packlets.app/docs/webhook.md#4-signatur-pruefen). ### Die App zeigt nicht, was ich erwarte | Was du siehst | Ursache | |---|---| | Die App wartet weiter auf „Verbunden“. | Sie wartet auf den ersten erfolgreichen `POST /v1/push`, bei einer App auf den ersten `PUT /v1/ui`. | | Ein Element ist breiter oder höher als angegeben. | Mindestgrößen, oder die App hat Platz für die Beschriftung geschaffen. Siehe [Layout](https://packlets.app/docs/layout.md). | | Formatierung erscheint als Zeichen. | Nur Überschriften, Listen und Fettdruck werden umgewandelt. Siehe [Formatierung](https://packlets.app/docs/senden.md#formatierung). | | Das Bild fehlt. | `image` muss eine öffentliche `http(s)`-Adresse sein, die das Handy direkt laden kann. | | Ein Aktions-Knopf lässt sich nicht noch einmal tippen. | So ist es gedacht: einmal pro Nachricht. Für mehrfaches Tippen einen [Button als Element](https://packlets.app/docs/nachrichten-elemente.md) nehmen. | | Die Seite einer App bleibt leer. | Nur `https://`. Im Browser öffnen und prüfen, ob sie dort lädt. | ### Immer noch unklar Schick den Aufruf einmal von Hand mit `curl -i`. So siehst du Status, Header und Body der Antwort, ohne dass dein eigener Code dazwischen steht: ```bash curl -i https://api.packlets.app/v1/push \ -H "Authorization: Bearer $SAMY_KEY" \ -H "Content-Type: application/json" \ -d '{"text": "Test"}' ``` --- ## Fertiger Code Bausteine zum Kopieren: Client für Python und TypeScript, Webhook-Empfänger und Shell. Bausteine zum Kopieren. Alle lesen den Schlüssel aus der Umgebungsvariable `SAMY_KEY`. ### Client für Python Eine Datei `samy.py` mit zwei Funktionen: Nachricht schicken und die Seite einer App setzen. ```python import os, requests # pip install requests API = "https://api.packlets.app" HEADERS = {"Authorization": f"Bearer {os.environ['SAMY_KEY']}"} def _call(method, path, body): res = requests.request(method, API + path, headers=HEADERS, json=body, timeout=10) if not res.ok: raise RuntimeError(f"Samy {res.status_code}: {res.text}") return res.json() def push(text, title=None, url=None, image=None, buttons=None, elements=None, priority="normal"): """Nachricht schicken. Gibt die message_id zurück.""" body = {"text": text, "title": title, "url": url, "image": image, "buttons": buttons or [], "elements": elements or [], "priority": priority} return _call("POST", "/v1/push", body)["message_id"] def set_page(page): """App: die Seite setzen, die die App zeigt (https-Adresse).""" return _call("PUT", "/v1/ui", {"page": page}) ``` So nutzt du sie: ```python from samy import push, set_page push("Backup von heute Nacht ist fertig.", title="Backup", priority="silent") push("shop-api antwortet seit 9:38 Uhr nicht.", title="Server down", priority="high", buttons=[{"id": "restart", "label": "Neu starten"}]) push("23 Bestellungen, 1.204 € Umsatz.", title="Tagesbericht", elements=[ {"type": "chart", "id": "umsatz", "label": "Umsatz der Woche", "unit": "€", "points": [{"label": "Mo", "value": 860}, {"label": "Di", "value": 1204}]}, ]) set_page("https://dein-server.de/rechnungen") # nur für ein Packlet der Art App ``` ### Client für TypeScript Eine Datei `samy.ts`, läuft mit Node.js 18 oder neuer ohne weitere Pakete. ```typescript const API = "https://api.packlets.app"; async function call(method: string, path: string, body: unknown): Promise { const res = await fetch(API + path, { method, headers: { Authorization: `Bearer ${process.env.SAMY_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`Samy ${res.status}: ${await res.text()}`); return (await res.json()) as T; } /** Nachricht schicken. Gibt die message_id zurück. */ export async function push(message: SamyMessage): Promise { return (await call<{ message_id: string }>("POST", "/v1/push", message)).message_id; } /** App: die Seite setzen, die die App zeigt (https-Adresse) */ export const setPage = (page: string) => call<{ ok: true; page: string }>("PUT", "/v1/ui", { page }); ``` ### Typen für TypeScript Gehören in dieselbe Datei. Damit prüft der Editor schon beim Schreiben, ob Nachrichten, Elemente und Ereignisse stimmen. ```typescript export type SamyMessage = { text: string; title?: string; url?: string; image?: string; buttons?: { id: string; label: string; url?: string }[]; elements?: SamyElement[]; priority?: "normal" | "high" | "silent"; }; type Layout = { /** Pro Nachricht eindeutig: Buchstaben, Ziffern, _ und -, 1 bis 40 Zeichen */ id: string; /** Spalten im Raster, Standard 6 (ganze Breite) */ width?: 1 | 2 | 3 | 4 | 5 | 6; /** Mindesthöhe in Punkten */ height?: number; }; export type SamyElement = | (Omit & { type: "text"; text: string; style?: "title" | "body" | "caption" }) | (Layout & { type: "button"; label: string; style?: "primary" | "secondary" }) | (Layout & { type: "toggle"; label: string; description?: string; value?: boolean }) | (Layout & { type: "input"; label: string; placeholder?: string; button?: string; value?: string }) | (Layout & { type: "chart"; label: string; points?: { label: string; value: number }[]; chart?: "bar" | "line"; unit?: string; highlight?: number; }) | (Layout & { type: "table"; columns: string[]; rows?: (string | number)[][]; label?: string }); /** Ereignisse am Webhook */ export type SamyEvent = | { type: "button"; packlet_id: string; message_id: string; button_id: string; pressed_at: string } | { type: "message"; packlet_id: string; message_id: string; text: string; sent_at: string } | { type: "ui"; packlet_id: string; /** Die Nachricht, zu der das Element gehört */ message_id: string; element_id: string; /** Schalter: true/false, Eingabefeld: Text, Button: null */ value: boolean | string | null; changed_at: string; }; ``` ### Webhook-Empfänger Prüft die Signatur und verteilt die drei Ereignisse. Die Erklärung dazu steht unter [Webhook einrichten](https://packlets.app/docs/webhook.md). ```python import hashlib, hmac, os from flask import Flask, request # pip install flask app = Flask(__name__) SECRET = hashlib.sha256(os.environ["SAMY_KEY"].encode()).hexdigest().encode() @app.post("/samy") def samy_webhook(): expected = "sha256=" + hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, request.headers.get("X-Samy-Signature", "")): return "", 401 event = request.get_json() if event["type"] == "button": print("Knopf", event["button_id"], "in Nachricht", event["message_id"]) elif event["type"] == "ui": print("Element", event["element_id"], "in Nachricht", event["message_id"], "ist jetzt", event["value"]) elif event["type"] == "message": print("Chat:", event["text"]) return "", 204 app.run(port=8080) ``` ```typescript import { createHash, createHmac, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; import type { SamyEvent } from "./samy.ts"; const secret = createHash("sha256").update(process.env.SAMY_KEY ?? "").digest("hex"); function isFromSamy(rawBody: string, signature = ""): boolean { const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")); const given = Buffer.from(signature); return expected.length === given.length && timingSafeEqual(expected, given); } createServer(async (req, res) => { let body = ""; for await (const chunk of req) body += chunk; if (!isFromSamy(body, req.headers["x-samy-signature"] as string | undefined)) { res.writeHead(401).end(); return; } res.writeHead(204).end(); // erst antworten, dann arbeiten const event = JSON.parse(body) as SamyEvent; if (event.type === "button") console.log("Knopf", event.button_id, "in Nachricht", event.message_id); else if (event.type === "ui") console.log("Element", event.element_id, "in Nachricht", event.message_id, "ist jetzt", event.value); else if (event.type === "message") console.log("Chat:", event.text); }).listen(8080); ``` ### Shell Für Cronjobs und Skripte. Die erste Fassung schickt JSON, die zweite ist die kürzeste, die geht: ```bash curl -fsS https://api.packlets.app/v1/push \ -H "Authorization: Bearer $SAMY_KEY" \ -H "Content-Type: application/json" \ -d '{"title":"Backup","text":"Backup von heute Nacht ist fertig.","priority":"silent"}' ``` ```bash curl -fsS https://api.packlets.app/v1/push -H "Authorization: Bearer $SAMY_KEY" -d text="Backup fertig" ``` Mit `-f` endet `curl` bei einem Fehler mit einem Status ungleich null. So merkt dein Skript, wenn etwas nicht geklappt hat. Eine Meldung nur im Fehlerfall: ```bash ./backup.sh || curl -fsS https://api.packlets.app/v1/push \ -H "Authorization: Bearer $SAMY_KEY" \ -d title="Backup" -d text="Das Backup ist fehlgeschlagen." -d priority=high ``` --- ## Schlüssel und Sicherheit Wie Samy mit deinem Schlüssel umgeht und was du selbst beachten solltest. Der Schlüssel ist das Einzige, was dein Tool von allen anderen unterscheidet. Wer ihn hat, kann in deinem Kanal Nachrichten schicken. Diese Seite sagt, wie Samy ihn schützt und was du selbst tun solltest. ### Der Schlüssel | | | |---|---| | Format | `samy_` und dahinter 32 Buchstaben und Ziffern | | Gilt für | genau ein Packlet | | Sichtbar | einmal, direkt nach dem Erzeugen. Danach zeigt die App ihn nur noch maskiert. | | Gespeichert bei Samy | nur als SHA-256-Hash. Den Klartext kennt Samy danach nicht mehr. | Mit dem Schlüssel lässt sich nur schicken: Nachrichten und die Adresse der Seite einer App. Nachrichten lesen, andere Packlets sehen oder dein Konto ändern kann man damit nicht. ### Was vom Schlüssel abhängt Aus dem Schlüssel entsteht auch das Geheimnis, mit dem Samy zwei Dinge unterschreibt: - die [Webhook-Ereignisse](https://packlets.app/docs/ereignisse.md#signatur) an dein Tool, - die [Session-Tokens](https://packlets.app/docs/api-seiten.md#session-token) für die Seite einer App. Dein Tool braucht dafür nichts Zusätzliches. Es bildet das Geheimnis selbst aus `SAMY_KEY`. ### Was du beachten solltest - **Umgebungsvariable statt Code.** Leg den Schlüssel in `SAMY_KEY` ab, nicht im Quelltext und nicht im Repository. Eine `.env`-Datei gehört in die `.gitignore`. - **Ein Packlet pro Tool.** So tauschst du einen einzelnen Schlüssel, ohne dass andere Tools ausfallen, und siehst im Protokoll, welches Tool was geschickt hat. - **Nie in eine Webseite.** Bei einer [App](https://packlets.app/docs/apps.md) bleibt der Schlüssel auf deinem Server. Was im HTML oder JavaScript steht, kann jeder lesen. - **Signatur immer prüfen.** Deine Webhook-Adresse ist öffentlich. Führ nichts aus, bevor die [Signatur](https://packlets.app/docs/webhook.md#4-signatur-pruefen) stimmt. - **Wiederholungen erkennen.** Darf etwas nicht doppelt passieren, prüf nach der Signatur auch `timestamp` und `event_id`: [Schutz vor Wiederholung](https://packlets.app/docs/ereignisse.md#schutz-vor-wiederholung). - **Eingaben bleiben Eingaben.** Texte aus dem Chat und aus Eingabefeldern kommen von außen. Reich sie nicht ungeprüft an eine Shell, eine Datenbankabfrage oder als Anweisung an eine KI mit weitreichenden Rechten weiter. - **Session-Token ganz prüfen.** Signatur, Ablauf und `aud`, bevor deine Seite private Daten zeigt. - **Einbetten regeln.** Fremde iframes in deiner Seite mit `sandbox`, deine Seite selbst mit `Content-Security-Policy: frame-ancestors 'none'`: [iframes und fremde Inhalte](https://packlets.app/docs/apps.md#5-iframes-und-fremde-inhalte). ### Wenn ein Schlüssel verloren geht Öffne die Einstellungen des Packlets und erzeuge einen neuen Schlüssel. Das passiert dann: - Der alte Schlüssel ist **sofort** ungültig. Aufrufe damit bekommen `401 invalid_key`. - Die App zeigt den neuen Schlüssel einmal an. Trag ihn in deinem Tool ein. - Webhook-Signaturen und Session-Tokens gelten ab jetzt mit dem neuen Schlüssel. - Kanal, Nachrichten und Einstellungen bleiben, wie sie sind. Hast du den Schlüssel nur vergessen, geht es genauso. Anzeigen lässt sich der alte nicht mehr. ### Wenn du ein Packlet entfernst Der Schlüssel ist sofort ungültig, und der Kanal mit allen Nachrichten wird gelöscht. Das lässt sich nicht rückgängig machen. ### Datenschutz Server und Datenbank stehen in der EU. Dein Konto löschst du in der App, zusammen mit allen Packlets und Nachrichten.