Standard Webhooks · HMAC-SHA256

Lotse ruft dein Backend an.Signiert, und wieder, bis es antwortet.

Lotse sagt deinem Backend, was passiert: Feedback, Umfrageantworten, zugeordnete Installationen, abgeschlossene Kampagnen, zugestellte Pushes, identifizierte und gelöschte Nutzer. Als JSON über HTTPS, signiert nach Standard Webhooks.

Als NächstesGebaut und getestet. Die Abnahme auf der Produktionsinstanz steht noch aus.

Einen Webhook anlegen

Nur der Besitzer eines Projekts legt Webhooks an, ändert und löscht sie, denn sie schicken Feedback und Nutzerdaten an einen anderen Server. Mitglieder sehen sie und ihre Zustellungen. Ein Projekt hat bis zu fünf.

  1. 1

    URL eintragen

    In der Console Developers öffnen, dann Webhooks, und Add webhook wählen. Die URL beginnt mit https:// und erreicht eine öffentliche Adresse. Private, lokale, Link-local-, Carrier-NAT- und Cloud-Metadaten-Adressen werden abgelehnt, beim Speichern und erneut bei jeder Verbindung. So kann ein später geänderter DNS-Eintrag einen Webhook nicht nach innen lenken.

  2. 2

    Ereignisse wählen

    Wähle, welche der zehn Ereignisse dieser Webhook bekommt. Jedes kommt mit vollem Inhalt. Die Auswahl lässt sich später ändern; die URL bleibt, eine neue URL ist ein neuer Webhook.

  3. 3

    Secret einmal kopieren

    Das Signing-Secret beginnt mit whsec_ und wird einmal angezeigt. Lotse speichert es verschlüsselt. Dann ein Test-Ereignis senden: webhook.test geht nur an diesen Webhook und erscheint in seinen Zustellungen.

Zehn Ereignisse

So gewählt, dass jedes eine Anfrage wert ist. Eigene SDK-Events und Link-Klicks gehören nicht dazu: Ein Abo auf Screen-Views wären Hunderttausende Zustellungen am Tag.

feedback.created

Ein Nutzer hat Feedback aus deiner App geschickt: Text, Kategorie, die Kontaktadresse, falls angegeben, deine Metadaten, der letzte Screen und das Gerät. Screenshot und Replay bleiben in der Console; consoleUrl führt hin.

JSON
{
  "type": "feedback.created",
  "timestamp": "2026-10-21T09:14:03.512Z",
  "data": {
    "id": 4182,
    "category": "bug",
    "status": "open",
    "message": "Checkout does nothing after I add a coupon.",
    "contactEmail": "ada@example.com",
    "lastScreen": "/checkout",
    "metadata": {
      "plan": "pro"
    },
    "sessionId": "0d6a8f2e-3b71-4c59-9e14-7a2b5c8d1f60",
    "userId": "u_42",
    "installId": "9b2f4c1e-5d7a-4e3b-8f06-2a1c9d4e7b30",
    "platform": "android",
    "osVersion": "14",
    "locale": "de_DE",
    "appVersion": "2.4.1",
    "createdAt": "2026-10-21T09:14:03.512Z",
    "consoleUrl": "https://console.lotse.dev/#/projects/12/feedback/4182",
    "screenshotUrl": "https://console.lotse.dev/#/projects/12/feedback/4182"
  }
}

feedback.replied

Eine neue Nachricht in einem Feedback-Gespräch, von deinem Team (author team) oder vom Nutzer (author user).

JSON
{
  "type": "feedback.replied",
  "timestamp": "2026-10-21T10:02:47.120Z",
  "data": {
    "id": 9931,
    "feedbackId": 4182,
    "author": "team",
    "body": "Thanks, this is fixed in 2.4.2.",
    "userId": "u_42",
    "installId": "9b2f4c1e-5d7a-4e3b-8f06-2a1c9d4e7b30",
    "createdAt": "2026-10-21T10:02:47.120Z",
    "consoleUrl": "https://console.lotse.dev/#/projects/12/feedback/4182"
  }
}

feature_request.created

Eine neue Idee auf dem Feature-Board, mit dem Feedback, aus dem sie entstand, falls es eines gibt.

JSON
{
  "type": "feature_request.created",
  "timestamp": "2026-10-21T09:30:12.004Z",
  "data": {
    "id": 77,
    "title": "Pay with PayPal",
    "description": "Several users asked for PayPal at checkout.",
    "status": "open",
    "sourceFeedbackId": 4180,
    "createdAt": "2026-10-21T09:30:12.004Z"
  }
}

survey.answered

Ein Nutzer hat eine Umfrage beantwortet: Wert oder Auswahl, die Frage und der Kommentar.

JSON
{
  "type": "survey.answered",
  "timestamp": "2026-10-21T09:41:55.318Z",
  "data": {
    "surveyId": 5,
    "kind": "nps",
    "question": "How likely are you to recommend Harbor to a friend?",
    "score": 9,
    "comment": "Fast checkout.",
    "userId": "u_42",
    "installId": "9b2f4c1e-5d7a-4e3b-8f06-2a1c9d4e7b30",
    "appVersion": "2.4.1",
    "createdAt": "2026-10-21T09:41:55.318Z"
  }
}

install.attributed

Eine neue Installation wurde einem deiner Links zugeordnet, über den Install Referrer oder einen probabilistischen Treffer, mit dem Link, wie die API ihn zeigt.

JSON
{
  "type": "install.attributed",
  "timestamp": "2026-10-21T08:57:31.902Z",
  "data": {
    "installId": "9b2f4c1e-5d7a-4e3b-8f06-2a1c9d4e7b30",
    "platform": "android",
    "source": "referrer",
    "link": {
      "id": 318,
      "slug": "rain-jacket",
      "url": "https://harbor.lotse.dev/l/rain-jacket",
      "targetPath": "/products/1042",
      "campaign": "catalogue",
      "source": "shop",
      "createdAt": "2026-10-20T16:02:10.441Z"
    },
    "attributedAt": "2026-10-21T08:57:31.902Z"
  }
}

campaign.finished

Eine Kampagne hat alle Pushes verschickt, mit ihrem Endstatus und den Zustellungen nach Status. Auch ein geplanter Versand, der nie rausging: Status failed, mit sendError.

JSON
{
  "type": "campaign.finished",
  "timestamp": "2026-10-21T09:14:03.512Z",
  "data": {
    "id": 57,
    "title": "Spring sale ends tonight",
    "body": "Your basket is still waiting.",
    "status": "sent",
    "linkId": 318,
    "segment": {
      "all": [
        {
          "field": "platform",
          "op": "eq",
          "value": "android"
        }
      ]
    },
    "createdAt": "2026-10-21T09:14:03.512Z",
    "scheduledAt": "2026-10-21T09:15:40.077Z",
    "sentAt": "2026-10-21T09:16:12.530Z",
    "deliveries": {
      "pending": 0,
      "sending": 0,
      "sent": 1180,
      "failed": 3,
      "invalidToken": 12
    }
  }
}

message.delivered

Ein Push an einen Nutzer oder eine Installation hat den Push-Dienst mindestens eines Geräts erreicht. So, wie GET /v1/messages/{id} ihn zeigt.

JSON
{
  "type": "message.delivered",
  "timestamp": "2026-10-21T09:14:03.512Z",
  "data": {
    "id": 905,
    "status": "sent",
    "userId": "u_42",
    "title": "Your order has shipped",
    "body": "Order 1042 is on its way.",
    "targetPath": "/orders/1042",
    "createdAt": "2026-10-21T09:14:03.512Z",
    "finishedAt": "2026-10-21T09:14:04.236Z",
    "deliveries": {
      "pending": 0,
      "sending": 0,
      "sent": 2,
      "failed": 0,
      "invalidToken": 0
    },
    "opened": 0
  }
}

message.failed

Ein Push an einen Nutzer oder eine Installation hat kein Gerät erreicht: Status failed, oder no_devices, wenn keines erreichbar war.

JSON
{
  "type": "message.failed",
  "timestamp": "2026-10-21T09:20:00.003Z",
  "data": {
    "id": 906,
    "status": "no_devices",
    "installId": "9b2f4c1e-5d7a-4e3b-8f06-2a1c9d4e7b30",
    "title": "Your table is ready",
    "body": "Show this at the entrance.",
    "createdAt": "2026-10-21T09:20:00.003Z",
    "finishedAt": "2026-10-21T09:20:00.003Z",
    "deliveries": {
      "pending": 0,
      "sending": 0,
      "sent": 0,
      "failed": 0,
      "invalidToken": 0
    },
    "opened": 0
  }
}

user.identified

Eine Installation wurde mit Lotse.identify an eine Nutzer-ID gebunden, mit den Attributen des Nutzers und ob ein gültiger User-Hash sie geprüft hat. Gemeldet wird nur eine neue Bindung, nicht jeder App-Start.

JSON
{
  "type": "user.identified",
  "timestamp": "2026-10-21T09:14:03.512Z",
  "data": {
    "userId": "u_42",
    "installId": "9b2f4c1e-5d7a-4e3b-8f06-2a1c9d4e7b30",
    "traits": {
      "plan": "pro"
    },
    "verified": true
  }
}

user.deleted

Ein Nutzer wurde gelöscht, in der Console oder über die API. Nur Nutzer-ID und Zeit: Lösche, was deine Systeme über ihn speichern.

JSON
{
  "type": "user.deleted",
  "timestamp": "2026-10-21T11:00:00.000Z",
  "data": {
    "userId": "u_42",
    "deletedAt": "2026-10-21T11:00:00.000Z"
  }
}

Der Body

Jede Zustellung ist ein POST mit JSON-Body: type, die Zeit des Ereignisses und seine data. Die Form ist die der Server-API: camelCase, Zeiten in UTC, Felder ohne Wert fehlen.

IDs, die dein Backend kennt

userId ist die ID, die deine App an identify übergibt, installId die Install-ID des SDK. IDs von Lotse stehen nur für das, was keine andere hat, etwa Feedback, Ideen, Umfragen, Links, Kampagnen und Messages.

Nie dabei

Push-Tokens, die Zugangsdaten von Installationen und was Lotse zu einem Link-Klick speichert: die gehashte IP-Adresse, den Fingerprint und den User-Agent. Screenshots und Replays kommen als Link in die Console.

Die Daten deiner Nutzer

Texte, Mail-Adressen und Attribute eingeschlossen. Behandle sie beim Empfänger wie in deiner eigenen App, und lösche sie, wenn user.deleted kommt.

Signatur prüfen

Lotse signiert nach Standard Webhooks, also prüfen die offiziellen Bibliotheken eine Zustellung mit einem Aufruf. Jede Zustellung trägt drei Header.

webhook-id
Die ID des Ereignisses, msg_…, bei jedem Versuch dieselbe. Damit überspringst du eine Zustellung, die du schon verarbeitet hast.
webhook-timestamp
Die Zeit dieses Versuchs in Unix-Sekunden. Lehne eine Zustellung ab, die mehr als 5 Minuten abweicht, damit eine aufgezeichnete nicht später wieder eingespielt werden kann.
webhook-signature
v1, und Base64 von HMAC-SHA256 über {webhook-id}.{webhook-timestamp}.{body}, mit dem Base64-dekodierten Teil des Secrets nach whsec_ als Schlüssel. Während einer Rotation kommen zwei Signaturen, durch ein Leerzeichen getrennt; eine passende genügt.
HTTP
POST /webhooks/lotse HTTP/1.1
content-type: application/json
user-agent: Lotse-Webhooks/1.0 (+https://lotse.dev)
webhook-id: msg_6f1d2c3b4a5e4f708192a3b4c5d6e7f8
webhook-timestamp: 1792574043
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NxkLWiJWc=

Prüfe den rohen Body, wie er ankam. Geparstes und neu geschriebenes JSON hat andere Bytes, und die Signatur passt nicht mehr.

Node.js

Mit der offiziellen Bibliothek standardwebhooks und Express.

JavaScript · npm install standardwebhooks express
import express from 'express';
import { Webhook } from 'standardwebhooks';

const webhook = new Webhook(process.env.LOTSE_WEBHOOK_SECRET); // whsec_…
const app = express();

// The raw body: the signature covers the bytes as sent, not parsed JSON.
const raw = express.raw({ type: 'application/json' });

app.post('/webhooks/lotse', raw, (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body, req.headers);
  } catch {
    return res.sendStatus(400);
  }
  // Answer within 5 seconds and do the work afterwards. webhook-id is the
  // same on every attempt, so a repeated delivery can be skipped.
  enqueue(req.headers['webhook-id'], event);
  res.sendStatus(204);
});

Python

Mit der offiziellen Bibliothek standardwebhooks und Flask.

Python · pip install standardwebhooks flask
import os

from flask import Flask, request
from standardwebhooks import Webhook, WebhookVerificationError

webhook = Webhook(os.environ["LOTSE_WEBHOOK_SECRET"])  # whsec_…
app = Flask(__name__)


@app.post("/webhooks/lotse")
def lotse_webhook():
    try:
        # The raw body: the signature covers the bytes as sent.
        event = webhook.verify(request.get_data(), request.headers)
    except (WebhookVerificationError, ValueError):
        return "", 400
    # Answer within 5 seconds and do the work afterwards. webhook-id is the
    # same on every attempt, so a repeated delivery can be skipped.
    enqueue(request.headers["webhook-id"], event)
    return "", 204

node:crypto

Dieselbe Prüfung von Hand, mit node:crypto.

JavaScript · node:crypto
import crypto from 'node:crypto';

// secret: whsec_…, headers: names in lower case, body: the raw body as a string.
export function verifyLotseWebhook(secret, headers, body) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures) return false;

  // At most 5 minutes off, so a recorded delivery cannot be replayed later.
  const seconds = Number(timestamp);
  const age = Math.abs(Date.now() / 1000 - seconds);
  if (!Number.isInteger(seconds) || age > 300) return false;

  // HMAC-SHA256 of "{id}.{timestamp}.{body}", key: the base64 after whsec_.
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.${body}`)
    .digest();

  // During a rotation two signatures come, separated by a space.
  return signatures.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const given = Buffer.from(signature, 'base64');
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

Dart

Von Hand in Dart, für ein Backend mit Serverpod, Dart Frog oder Shelf.

Dart · package:crypto
import 'dart:convert';

import 'package:crypto/crypto.dart';

/// Whether Lotse signed [body], the raw body as received, with [secret]
/// (whsec_…). [headers] with names in lower case.
bool verifyLotseWebhook(
  String secret,
  Map<String, String> headers,
  String body,
) {
  final id = headers['webhook-id'];
  final timestamp = headers['webhook-timestamp'];
  final signatures = headers['webhook-signature'];
  final seconds = int.tryParse(timestamp ?? '');
  if (id == null || seconds == null || signatures == null) return false;

  // At most 5 minutes off, so a recorded delivery cannot be replayed later.
  final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
  if ((now - seconds).abs() > 300) return false;

  // HMAC-SHA256 of "{id}.{timestamp}.{body}", key: the base64 after whsec_.
  final hmac = Hmac(sha256, base64.decode(secret.substring('whsec_'.length)));
  final expected = hmac.convert(utf8.encode('$id.$timestamp.$body')).bytes;

  // During a rotation two signatures come, separated by a space.
  for (final entry in signatures.split(' ')) {
    if (!entry.startsWith('v1,')) continue;
    final List<int> given;
    try {
      given = base64.decode(entry.substring(3));
    } on FormatException {
      continue;
    }
    if (_equal(given, expected)) return true;
  }
  return false;
}

/// Compares in constant time.
bool _equal(List<int> a, List<int> b) {
  if (a.length != b.length) return false;
  var difference = 0;
  for (var i = 0; i < a.length; i++) {
    difference |= a[i] ^ b[i];
  }
  return difference == 0;
}

Das Secret rotieren

Rotate secret in der Console erzeugt ein neues Secret. Was mit dem alten passiert, wählst du.

Es signiert noch 24 Stunden

Jede Zustellung trägt beide Signaturen, die neue zuerst. Roll das neue Secret innerhalb des Tages aus; die Bibliotheken nehmen die, die passt.

Es endet sofort

Für ein geleaktes Secret: Ab jetzt signiert nur das neue. Zustellungen, die dein Backend bis dahin ablehnt, werden wiederholt.

Zustellung

Mindestens einmal, ohne garantierte Reihenfolge: Wiederholungen können neuere Ereignisse überholen, und ein Ereignis kann zweimal ankommen. Sortiere nach dem timestamp im Body und überspringe nach webhook-id.

2xx binnen 5 Sekunden

Nur das gilt als zugestellt. Weiterleitungen werden nicht verfolgt, und von der Antwort liest Lotse höchstens 4 KiB. Erst antworten, dann arbeiten.

Wiederholungen über etwa einen Tag

8 Versuche: nach 1 Minute, nach 5 und 30 Minuten, dann nach 2, 5, 8 und 8 Stunden. Nach fünf Fehlschlägen in Folge pausiert Lotse den Webhook für 5 Minuten und schickt dann eine einzelne Probe; jede gescheiterte Probe verdoppelt die Pause, bis zu einer Stunde.

Abgeschaltet nach 3 Tagen

Ein Webhook, der 3 Tage lang keinen Erfolg hatte, wird abgeschaltet, seine wartenden Zustellungen werden abgebrochen, und der Besitzer bekommt eine Mail. Backend reparieren, dann den Webhook in der Console wieder einschalten.

10.000 am Tag

Zustellungen je Projekt und Tag; Wiederholungen kosten nichts. Was nicht mehr passt, steht als budget exceeded im Protokoll. Solange ein Projekt gesperrt ist, warten seine Zustellungen.

Ein Protokoll über 30 Tage

Developers, Webhooks zeigt jede Zustellung mit ihren Versuchen, dem letzten Antwortcode und der Dauer. Der Inhalt eines Ereignisses bleibt 7 Tage; bis dahin stellt Send again es noch einmal zu.

Test-Ereignisse

Send test event schickt webhook.test an einen Webhook, egal was er abonniert hat. Test-Ereignisse und erneut gesendete Zustellungen teilen sich 100 je Stunde und Projekt.

Die andere Richtung: die Server-API

Kurs setzen.Das erste Projekt in zehn Minuten.

Kostenlos während der Beta. Registrieren, Projekt anlegen, SDK einbinden.