# 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).
