Standard Webhooks · HMAC-SHA256

Lotse calls your backend.Signed, and again until it answers.

Lotse tells your backend what happens: feedback, survey answers, attributed installs, finished campaigns, delivered pushes, identified and deleted users. As JSON over HTTPS, signed according to Standard Webhooks.

Coming nextBuilt and tested. The acceptance on the production instance is still open.

Add a webhook

Only the owner of a project adds, edits and deletes webhooks, because they send feedback and user data to another server. Members see them and their deliveries. A project has up to five.

  1. 1

    Enter the URL

    In the console, open Developers, then Webhooks, and choose Add webhook. The URL starts with https:// and reaches a public address. Private, local, link-local, carrier NAT and cloud metadata addresses are refused, when you save and again on every connection, so a DNS record changed later cannot turn a webhook inward.

  2. 2

    Pick the events

    Choose which of the ten events this webhook gets. Each comes with its full content. You can change the choice later; the URL stays, and a new URL is a new webhook.

  3. 3

    Copy the secret, once

    The signing secret starts with whsec_ and is shown once. Lotse stores it encrypted. Then send a test event: webhook.test goes to this webhook only and shows up in its deliveries.

Ten events

Chosen so that each one is worth a request. Your own SDK events and link clicks are not among them: a subscription to screen views would be hundreds of thousands of deliveries a day.

feedback.created

A user sent feedback from your app: text, category, the contact e-mail if given, your metadata, the last screen and the device. Screenshot and replay stay in the console; consoleUrl leads there.

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

A new message in a feedback conversation, from your team (author team) or from the user (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

A new idea on the feature board, with the feedback it came from, if any.

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

A user answered a survey: score or choice, the question and the comment.

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

A new install was credited to one of your links, through the install referrer or a probabilistic match, with the link as the API shows it.

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

A campaign has sent all its pushes, with its final status and the deliveries by status. Also a scheduled send that never went out: status failed, with 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

A push to one user or install reached the push service of at least one device. As GET /v1/messages/{id} shows it.

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

A push to one user or install reached no device: status failed, or no_devices when there was none to reach.

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

An install was tied to a user id with Lotse.identify, with the traits of the user and whether a valid user hash verified it. Only a new tie is reported, not every 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

A user was deleted, in the console or through the API. Only the user id and the time: delete what your systems keep about them.

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

The body

Every delivery is a POST with a JSON body: type, the time of the event and its data. The form is that of the server API: camelCase, times in UTC, fields without a value left out.

Ids your backend knows

userId is the id your app passes to identify, installId the install id of the SDK. Ids of Lotse appear only for what has no other, such as feedback, ideas, surveys, links, campaigns and messages.

Never included

Push tokens, the credentials of installs, and what Lotse keeps about a link click: the hashed IP address, the fingerprint and the user agent. Screenshots and replays come as a link into the console.

The data of your users

Texts, e-mail addresses and traits included. Treat them at the receiving end as you do in your own app, and delete them when user.deleted arrives.

Verify the signature

Lotse signs according to Standard Webhooks, so the official libraries verify a delivery in one call. Every delivery carries three headers.

webhook-id
The id of the event, msg_…, the same on every attempt. Use it to skip a delivery you have handled already.
webhook-timestamp
The time of this attempt in Unix seconds. Refuse a delivery more than 5 minutes off, so a recorded one cannot be replayed.
webhook-signature
v1, and the base64 of HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}, keyed with the base64-decoded part of the secret after whsec_. During a rotation two signatures come, separated by a space; one that matches is enough.
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=

Verify the raw body as it arrived. JSON parsed and written again has other bytes, and the signature no longer fits.

Node.js

With the official library standardwebhooks and 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

With the official library standardwebhooks and 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

The same check by hand, with 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

By hand in Dart, for a backend on Serverpod, Dart Frog or 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;
}

Rotating the secret

Rotate secret in the console creates a new secret. You choose what happens to the old one.

It keeps signing for 24 hours

Every delivery carries both signatures, the new one first. Deploy the new secret within the day; the libraries take whichever matches.

It stops now

For a secret that leaked: from now on only the new one signs. Deliveries your backend refuses until then are retried.

Delivery

At least once, without a guaranteed order: retries can overtake newer events, and an event can arrive twice. Order by the timestamp in the body, and skip by webhook-id.

2xx within 5 seconds

Only that counts as delivered. Redirects are not followed, and of the answer Lotse reads at most 4 KiB. Answer first, work afterwards.

Retries for about a day

8 attempts: after 1 minute, after 5 and 30 minutes, then after 2, 5, 8 and 8 hours. After five failures in a row Lotse pauses the webhook for 5 minutes and then sends a single probe; each failed probe doubles the pause, up to an hour.

Switched off after 3 days

A webhook without a single success for 3 days is switched off, its waiting deliveries are cancelled, and the owner gets a mail. Fix your backend, then switch the webhook on again in the console.

10,000 a day

Deliveries per project and day; retries cost nothing. What does not fit ends as budget exceeded in the log. While a project is suspended, its deliveries wait.

A log of 30 days

Developers, Webhooks shows every delivery with its attempts, the last answer code and the duration. The content of an event is kept for 7 days; until then Send again delivers it once more.

Test events

Send test event posts webhook.test to one webhook, whatever it subscribed to. Test events and deliveries sent again share 100 an hour per project.

The other direction: the server API

Set your course.First project in ten minutes.

Free during the beta. Sign up, create a project, add the SDK.