Developers
Contents
On this page

Webhooks

Webhooks

Line Me Up sends a signed POST to your URL when something changes.

Overview#

A webhook endpoint is a URL of yours. When a visit, a line or a desk changes, Line Me Up sends that URL a POST with a JSON body describing the change, usually within a couple of seconds. Your receiver checks the signature, answers 200, and does whatever it does with it.

  • Only https:// addresses, on a public host name.
  • Every delivery is signed with a secret only the business — and you — hold.
  • A delivery that fails is retried for about 18 hours, then given up.
  • Every attempt is in a delivery log the owner can read, filter and redeliver from.

Every delivery has the same envelope:

The envelope
FieldIs
id The event's id, evt-…. The same on every retry and on a redelivery — dedupe on it.
type Which event — the catalogue below.
apiVersion Always "v1" in this version.
createdAt When the change happened, UTC.
businessId The business it happened at — useful when one receiver serves several.
test true for Send test event: a sample, not something that happened.
data What happened: the visit, line or desk as it is after the change, and for some types what it was before.

And these headers:

A delivery's headers (the signature shortened)
Content-Type: application/json
User-Agent: LineMeUp-Webhooks/1
LineMeUp-Signature: t=1767225600,v1=…
LineMeUp-Event-Id: evt-0193f2a1-7c4e-7a10-9b2e-000000000001
LineMeUp-Event-Type: visit.created
LineMeUp-Delivery-Id: whd-0193f2a1-7c4e-7a10-9b2e-000000000001
  • LineMeUp-Signature — the proof it is from us; see below.
  • LineMeUp-Event-Id — the event's id, the same on every retry.
  • LineMeUp-Event-Type — its type, so you can route before parsing.
  • LineMeUp-Delivery-Id — this delivery's id, which a redelivery changes.

Visit events carry the customer's name and phone number — and nothing else a person typed: never the note the desk keeps on a visit, which GET /v1/visits/{id} still reads. That is so a CRM or a clinic system can match the visit to its own record. It is personal data the business is sending you: keep it only as long as you need, and as carefully as they would. When a customer deletes their Line Me Up account, the copies kept in the delivery log are cleared too.

Setting up an endpoint#

  1. The owner opens Settings → Integrations and, under Webhooks, chooses Add endpoint.
  2. They enter the URL and, if they like, a description — "Clinic CRM, production" — and choose its events and lines (next section).
  3. The signing secret, whsec_…, is shown once. Put it in your receiver's configuration — never in its code. Line Me Up keeps it encrypted and cannot show it again.
  4. Press Send test event to check your receiver before anything real happens.

A business may have 10 endpoints. The address must be a host name on the public internet: an IP address, localhost, a .local or .internal name, or a URL with a user name and password in it is refused. We check the address again each time we send, and refuse one that has come to point at a private network.

Rolling the secret#

Roll secret makes a new secret and shows it once. For the next 24 hours every delivery is signed with both — two v1= in the header — so you can deploy the new secret whenever you are ready, and nothing is refused in between. After 24 hours only the new one signs.

During a roll
LineMeUp-Signature: t=1767225600,v1=<signed with the new secret>,v1=<signed with the old>

Switching off and deleting#

An endpoint can be switched off and on again; while off, nothing is sent to it — no events, no test event, no redelivery. If Line Me Up support switches an endpoint off, Settings shows it as Disabled by Line Me Up support with the reason, and only support can switch it back on. Deleting one deletes its delivery log with it.

Choosing events and lines#

An endpoint receives only the event types ticked for it — at least one — and, if it is given some lines, only the changes on those lines:

  • a visit event, if the visit is on one of them;
  • line.paused and line.resumed, for those lines;
  • desk.status_changed, for a desk that calls from any of them.

Two endpoints may take the same events; each gets its own delivery, with its own retries and log. A business on Free, the live demo and simulated businesses send no webhooks.

Event catalogue#

Every event, and when it is sent
TypeSent when
visit.created A visit is added: a walk-in at the desk or through the API, a customer joining from their phone, or a booking being made.
visit.status_changed A visit takes any step — called, served, completed, cancelled, a no-show, put back, checked in — whether a person did it or Line Me Up did (a missed call, a missed booking, the day closing). data.from and data.to say which.
visit.updated Something about a visit changes other than its status: its urgency, its booked slot, its line, or who it is for. data.changed lists which. A change to the desk's note alone sends nothing: webhooks never carry the note.
line.paused A line is paused, by a person or the API.
line.resumed A paused line is resumed.
desk.status_changed A desk's shift starts or ends, or it goes on or comes back from a break. Calling and serving are visit events, not this.
webhook.test Only when the owner presses Send test event without choosing another type. It cannot be subscribed to.

The data is the resource as it is after the change, in exactly the shape the API answers — a visit here is the visit GET /v1/visits/{visitId} would return. Every field is in the reference.

visit.created#

A visit is added: a walk-in at the desk or through the API, a customer joining from their phone, or a booking being made.

Example payload
{
  "id": "evt-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "type": "visit.created",
  "apiVersion": "v1",
  "createdAt": "2026-10-30T04:12:00.000Z",
  "businessId": "bus-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "test": false,
  "data": {
    "visit": {
      "id": "job-0193f2a1-7c4e-7a10-9b2e-000000000001",
      "journeyId": "jny-0193f2a1-7c4e-7a10-9b2e-000000000001",
      "token": "GEN-20261030-014",
      "lineId": "line-0193f2a1-7c4e-7a10-9b2e-000000000001",
      "status": "queued",
      "bookingType": "walk_in",
      "priority": false,
      "customer": {
        "name": "Asha Rao",
        "phone": "+919000000001"
      },
      "services": [
        {
          "id": "svc-0193f2a1-7c4e-7a10-9b2e-000000000002",
          "name": "General consultation"
        }
      ],
      "deskId": null,
      "position": 3,
      "slotAt": null,
      "createdAt": "2026-10-30T04:12:00.000Z",
      "calledAt": null,
      "startedAt": null,
      "endedAt": null,
      "updatedAt": "2026-10-30T04:12:00.000Z"
    }
  }
}

visit.status_changed#

A visit takes any step — called, served, completed, cancelled, a no-show, put back, checked in — whether a person did it or Line Me Up did (a missed call, a missed booking, the day closing). data.from and data.to say which.

Example payload
{
  "id": "evt-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "type": "visit.status_changed",
  "apiVersion": "v1",
  "createdAt": "2026-10-30T04:31:00.000Z",
  "businessId": "bus-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "test": false,
  "data": {
    "visit": {
      "id": "job-0193f2a1-7c4e-7a10-9b2e-000000000001",
      "journeyId": "jny-0193f2a1-7c4e-7a10-9b2e-000000000001",
      "token": "GEN-20261030-014",
      "lineId": "line-0193f2a1-7c4e-7a10-9b2e-000000000001",
      "status": "called",
      "bookingType": "walk_in",
      "priority": false,
      "customer": {
        "name": "Asha Rao",
        "phone": "+919000000001"
      },
      "services": [
        {
          "id": "svc-0193f2a1-7c4e-7a10-9b2e-000000000002",
          "name": "General consultation"
        }
      ],
      "deskId": "sta-0193f2a1-7c4e-7a10-9b2e-000000000001",
      "position": null,
      "slotAt": null,
      "createdAt": "2026-10-30T04:12:00.000Z",
      "calledAt": "2026-10-30T04:31:00.000Z",
      "startedAt": null,
      "endedAt": null,
      "updatedAt": "2026-10-30T04:31:00.000Z"
    },
    "from": "queued",
    "to": "called"
  }
}

visit.updated#

Something about a visit changes other than its status: its urgency, its booked slot, its line, or who it is for. data.changed lists which. A change to the desk's note alone sends nothing: webhooks never carry the note.

Example payload
{
  "id": "evt-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "type": "visit.updated",
  "apiVersion": "v1",
  "createdAt": "2026-10-30T04:15:00.000Z",
  "businessId": "bus-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "test": false,
  "data": {
    "visit": {
      "id": "job-0193f2a1-7c4e-7a10-9b2e-000000000001",
      "journeyId": "jny-0193f2a1-7c4e-7a10-9b2e-000000000001",
      "token": "GEN-20261030-014",
      "lineId": "line-0193f2a1-7c4e-7a10-9b2e-000000000001",
      "status": "queued",
      "bookingType": "walk_in",
      "priority": true,
      "customer": {
        "name": "Asha Rao",
        "phone": "+919000000001"
      },
      "services": [
        {
          "id": "svc-0193f2a1-7c4e-7a10-9b2e-000000000002",
          "name": "General consultation"
        }
      ],
      "deskId": null,
      "position": 1,
      "slotAt": null,
      "createdAt": "2026-10-30T04:12:00.000Z",
      "calledAt": null,
      "startedAt": null,
      "endedAt": null,
      "updatedAt": "2026-10-30T04:15:00.000Z"
    },
    "changed": [
      "priority"
    ]
  }
}

line.paused#

A line is paused, by a person or the API.

Example payload
{
  "id": "evt-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "type": "line.paused",
  "apiVersion": "v1",
  "createdAt": "2026-10-30T06:30:00.000Z",
  "businessId": "bus-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "test": false,
  "data": {
    "line": {
      "id": "line-0193f2a1-7c4e-7a10-9b2e-000000000001",
      "name": "General",
      "code": "GEN",
      "type": "walk_in",
      "paused": true,
      "waiting": 4,
      "avgServiceMinutes": 10,
      "maxCapacity": 60,
      "description": "Walk-ins for a first visit",
      "location": "Ground floor",
      "callGraceMinutes": 5,
      "appointmentGraceBeforeMinutes": 10,
      "maxLateArrivalMinutes": 15,
      "autoNoShowEnabled": true
    }
  }
}

line.resumed#

A paused line is resumed.

Example payload
{
  "id": "evt-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "type": "line.resumed",
  "apiVersion": "v1",
  "createdAt": "2026-10-30T07:00:00.000Z",
  "businessId": "bus-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "test": false,
  "data": {
    "line": {
      "id": "line-0193f2a1-7c4e-7a10-9b2e-000000000001",
      "name": "General",
      "code": "GEN",
      "type": "walk_in",
      "paused": false,
      "waiting": 4,
      "avgServiceMinutes": 10,
      "maxCapacity": 60,
      "description": "Walk-ins for a first visit",
      "location": "Ground floor",
      "callGraceMinutes": 5,
      "appointmentGraceBeforeMinutes": 10,
      "maxLateArrivalMinutes": 15,
      "autoNoShowEnabled": true
    }
  }
}

desk.status_changed#

A desk's shift starts or ends, or it goes on or comes back from a break. Calling and serving are visit events, not this.

Example payload
{
  "id": "evt-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "type": "desk.status_changed",
  "apiVersion": "v1",
  "createdAt": "2026-10-30T06:30:00.000Z",
  "businessId": "bus-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "test": false,
  "data": {
    "desk": {
      "id": "sta-0193f2a1-7c4e-7a10-9b2e-000000000001",
      "name": "Dr Mehta",
      "type": "person",
      "publicLabel": "Room 1",
      "active": true,
      "state": "break",
      "lineIds": [
        "line-0193f2a1-7c4e-7a10-9b2e-000000000001"
      ]
    },
    "from": "available",
    "to": "break"
  }
}

webhook.test#

Only when the owner presses Send test event without choosing another type. It cannot be subscribed to.

Example payload
{
  "id": "evt-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "type": "webhook.test",
  "apiVersion": "v1",
  "createdAt": "2026-10-30T05:00:00.000Z",
  "businessId": "bus-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "test": true,
  "data": {
    "message": "A test event from Line Me Up. Your endpoint is receiving webhooks."
  }
}

Verifying signatures#

Anyone can send a POST to your URL. Check every delivery's signature before you believe a word of it:

LineMeUp-Signature: t=1767225600,v1=8121c9169bb7f71dc187cf9f286d6774f09debfa91503a20359a2e875c99e65b
  1. Read t, the time we signed, in unix seconds, and every v1 from the LineMeUp-Signature header.
  2. Refuse the delivery if t is more than five minutes from your clock — that stops somebody replaying an old one.
  3. Compute the HMAC-SHA256 of t, a full stop, and the raw body, keyed with your secret:
signed = t + "." + rawBody
v1     = hex( HMAC-SHA256( key = secret, message = signed ) )
  1. Accept the delivery if any v1 equals it, compared in constant time. There is one v1 normally and two during a roll.

Use the raw body, the bytes exactly as they arrived. Parsing the JSON and turning it back into text changes spacing and order, and the signature will not match. Each framework below has a way to keep the raw body; the samples use it.

Answer quickly: we wait up to 10 seconds for a 2xx, then count the attempt as failed. Answer first and do the work afterwards, from a queue of your own.

200 OK            ← answer first, within 10 seconds
… then do the work, from your own queue

Node#

No dependencies. This is the code the site's own build runs against the test vectors below.

verify-line-me-up.mjs
// verify-line-me-up.mjs — Node 18 or later, no dependencies.
import { createHmac, timingSafeEqual } from "node:crypto";

/**
 * Whether a delivery really came from Line Me Up.
 *
 * rawBody: the request body exactly as it arrived (a Buffer or a string) —
 *          never JSON you parsed and turned back into text.
 * header:  the LineMeUp-Signature header.
 * secret:  your endpoint's signing secret, whsec_…
 */
export function verifyLineMeUp(
  rawBody,
  header,
  secret,
  { toleranceSeconds = 300, nowSeconds = Math.floor(Date.now() / 1000) } = {},
) {
  if (typeof header !== "string" || typeof secret !== "string") return false;
  const parts = header.split(",").map((part) => part.trim());
  const t = parts.find((part) => part.startsWith("t="))?.slice(2);
  if (t === undefined || !/^\d+$/.test(t)) return false;
  if (Math.abs(nowSeconds - Number(t)) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  return parts
    .filter((part) => part.startsWith("v1="))
    .some((part) => {
      const given = Buffer.from(part.slice(3), "hex");
      return given.length === expected.length && timingSafeEqual(given, expected);
    });
}
server.mjs — receiving with Express
// server.mjs — npm install express
import express from "express";
import { verifyLineMeUp } from "./verify-line-me-up.mjs";

const app = express();

// express.raw, not express.json: the signature is over the bytes as sent.
app.post("/webhooks/line-me-up", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyLineMeUp(
    req.body,
    req.get("LineMeUp-Signature"),
    process.env.LINEMEUP_WEBHOOK_SECRET,
  );
  if (!ok) return res.status(400).send("Bad signature");

  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(200); // answer within 10 seconds, then do the work
  queueForProcessing(event); // your own queue; dedupe on event.id
});

app.listen(3000);

Python#

The standard library only for the check; Flask for the receiver.

verify_line_me_up.py
# verify_line_me_up.py — Python 3.8 or later, standard library only.
import hashlib
import hmac
import time


def verify_line_me_up(raw_body, header, secret, tolerance=300, now=None):
    """Whether a delivery really came from Line Me Up.

    raw_body: the request body as bytes, exactly as it arrived.
    header:   the LineMeUp-Signature header.
    secret:   your endpoint's signing secret, whsec_...
    """
    if not header or not secret:
        return False
    parts = [part.strip() for part in header.split(",")]
    t = next((part[2:] for part in parts if part.startswith("t=")), None)
    if t is None or not t.isdigit():
        return False
    now = int(time.time()) if now is None else now
    if abs(now - int(t)) > tolerance:
        return False

    signed = t.encode("ascii") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return any(
        hmac.compare_digest(part[3:], expected) for part in parts if part.startswith("v1=")
    )


# Flask — pip install flask
import json
import os

from flask import Flask, request

app = Flask(__name__)


@app.post("/webhooks/line-me-up")
def line_me_up():
    raw_body = request.get_data()  # the bytes, before any parsing
    header = request.headers.get("LineMeUp-Signature", "")
    if not verify_line_me_up(raw_body, header, os.environ["LINEMEUP_WEBHOOK_SECRET"]):
        return "Bad signature", 400
    event = json.loads(raw_body)
    queue_for_processing(event)  # your own queue; dedupe on event["id"]
    return "", 200

PHP#

PHP 8, no extensions beyond the defaults.

verify_line_me_up.php
<?php
// verify_line_me_up.php — PHP 8.0 or later, no extensions beyond the defaults.

/**
 * Whether a delivery really came from Line Me Up.
 *
 * $rawBody: the request body exactly as it arrived (php://input).
 * $header:  the LineMeUp-Signature header.
 * $secret:  your endpoint's signing secret, whsec_…
 */
function verify_line_me_up(
    string $rawBody,
    string $header,
    string $secret,
    int $tolerance = 300,
    ?int $now = null
): bool {
    $t = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        $part = trim($part);
        if (str_starts_with($part, 't=')) {
            $t = substr($part, 2);
        } elseif (str_starts_with($part, 'v1=')) {
            $signatures[] = substr($part, 3);
        }
    }
    if ($t === null || !ctype_digit($t)) {
        return false;
    }
    $now = $now ?? time();
    if (abs($now - (int) $t) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }
    return false;
}

// Receiving:
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_LINEMEUP_SIGNATURE'] ?? '';
if (!verify_line_me_up($rawBody, $header, getenv('LINEMEUP_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit('Bad signature');
}
$event = json_decode($rawBody, true);
http_response_code(200);
// Queue $event for processing after answering; dedupe on $event['id'].

Go#

The standard library only.

main.go
// main.go — Go 1.20 or later, standard library only.
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

// VerifyLineMeUp reports whether a delivery really came from Line Me Up.
// rawBody must be the request body exactly as it arrived; header is
// LineMeUp-Signature; secret is your endpoint's signing secret, whsec_….
func VerifyLineMeUp(rawBody []byte, header, secret string, tolerance time.Duration, now time.Time) bool {
	var t string
	var signatures []string
	for _, part := range strings.Split(header, ",") {
		part = strings.TrimSpace(part)
		switch {
		case strings.HasPrefix(part, "t="):
			t = part[2:]
		case strings.HasPrefix(part, "v1="):
			signatures = append(signatures, part[3:])
		}
	}
	if t == "" || strings.Trim(t, "0123456789") != "" {
		return false
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil {
		return false
	}
	age := now.Unix() - ts
	if age < 0 {
		age = -age
	}
	if age > int64(tolerance/time.Second) {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)
	for _, s := range signatures {
		given, err := hex.DecodeString(s)
		if err == nil && hmac.Equal(given, expected) {
			return true
		}
	}
	return false
}

func lineMeUp(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "Unreadable body", http.StatusBadRequest)
		return
	}
	header := r.Header.Get("LineMeUp-Signature")
	if !VerifyLineMeUp(rawBody, header, os.Getenv("LINEMEUP_WEBHOOK_SECRET"), 5*time.Minute, time.Now()) {
		http.Error(w, "Bad signature", http.StatusBadRequest)
		return
	}
	var event struct {
		ID   string          `json:"id"`
		Type string          `json:"type"`
		Data json.RawMessage `json:"data"`
	}
	if err := json.Unmarshal(rawBody, &event); err != nil {
		http.Error(w, "Not JSON", http.StatusBadRequest)
		return
	}
	w.WriteHeader(http.StatusOK)
	// Queue event for processing after answering; dedupe on event.ID.
}

func main() {
	http.HandleFunc("/webhooks/line-me-up", lineMeUp)
	_ = http.ListenAndServe(":3000", nil)
}

Test vectors#

Run your verifier on these before you trust it. Each gives the secret, the body, the header as it arrives and the receiver's clock (now, unix seconds), and whether it should pass. The secrets are fake — one letter forty-three times — and are never used by any business.

What each vector checks
VectorShould
One secret Pass
Another secret, same body and time Pass
During Roll secret: two signatures, checked with the new secret Pass
During Roll secret: two signatures, checked with the old secret Pass
An empty body at t=0 — the signed text is just “0.” Pass
Four minutes late — still inside the five-minute tolerance Pass
The body changed by one space Fail
The wrong secret Fail
Too old — five minutes and one second Fail
No t= in the header Fail
The vectors, as JSON
[
  {
    "name": "One secret",
    "secret": "whsec_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "body": "{\"id\":\"evt-test\",\"type\":\"webhook.test\"}",
    "header": "t=1767225600,v1=8121c9169bb7f71dc187cf9f286d6774f09debfa91503a20359a2e875c99e65b",
    "now": 1767225600,
    "valid": true
  },
  {
    "name": "Another secret, same body and time",
    "secret": "whsec_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    "body": "{\"id\":\"evt-test\",\"type\":\"webhook.test\"}",
    "header": "t=1767225600,v1=d1c064106654840a74d3c50811840a5e784a4d71e8aefe9ea6833db05fde1aa1",
    "now": 1767225600,
    "valid": true
  },
  {
    "name": "During Roll secret: two signatures, checked with the new secret",
    "secret": "whsec_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    "body": "{\"id\":\"evt-test\",\"type\":\"webhook.test\"}",
    "header": "t=1767225600,v1=d1c064106654840a74d3c50811840a5e784a4d71e8aefe9ea6833db05fde1aa1,v1=8121c9169bb7f71dc187cf9f286d6774f09debfa91503a20359a2e875c99e65b",
    "now": 1767225600,
    "valid": true
  },
  {
    "name": "During Roll secret: two signatures, checked with the old secret",
    "secret": "whsec_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "body": "{\"id\":\"evt-test\",\"type\":\"webhook.test\"}",
    "header": "t=1767225600,v1=d1c064106654840a74d3c50811840a5e784a4d71e8aefe9ea6833db05fde1aa1,v1=8121c9169bb7f71dc187cf9f286d6774f09debfa91503a20359a2e875c99e65b",
    "now": 1767225600,
    "valid": true
  },
  {
    "name": "An empty body at t=0 — the signed text is just “0.”",
    "secret": "whsec_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "body": "",
    "header": "t=0,v1=20a6f56986af223065295efdd6923ab05c052a1ac3931f3d10a9dca70749f44b",
    "now": 0,
    "valid": true
  },
  {
    "name": "Four minutes late — still inside the five-minute tolerance",
    "secret": "whsec_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "body": "{\"id\":\"evt-test\",\"type\":\"webhook.test\"}",
    "header": "t=1767225600,v1=8121c9169bb7f71dc187cf9f286d6774f09debfa91503a20359a2e875c99e65b",
    "now": 1767225840,
    "valid": true
  },
  {
    "name": "The body changed by one space",
    "secret": "whsec_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "body": "{\"id\":\"evt-test\",\"type\":\"webhook.test\"} ",
    "header": "t=1767225600,v1=8121c9169bb7f71dc187cf9f286d6774f09debfa91503a20359a2e875c99e65b",
    "now": 1767225600,
    "valid": false
  },
  {
    "name": "The wrong secret",
    "secret": "whsec_ccccccccccccccccccccccccccccccccccccccccccc",
    "body": "{\"id\":\"evt-test\",\"type\":\"webhook.test\"}",
    "header": "t=1767225600,v1=8121c9169bb7f71dc187cf9f286d6774f09debfa91503a20359a2e875c99e65b",
    "now": 1767225600,
    "valid": false
  },
  {
    "name": "Too old — five minutes and one second",
    "secret": "whsec_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "body": "{\"id\":\"evt-test\",\"type\":\"webhook.test\"}",
    "header": "t=1767225600,v1=8121c9169bb7f71dc187cf9f286d6774f09debfa91503a20359a2e875c99e65b",
    "now": 1767225901,
    "valid": false
  },
  {
    "name": "No t= in the header",
    "secret": "whsec_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "body": "{\"id\":\"evt-test\",\"type\":\"webhook.test\"}",
    "header": "v1=8121c9169bb7f71dc187cf9f286d6774f09debfa91503a20359a2e875c99e65b",
    "now": 1767225600,
    "valid": false
  }
]

Retries and backoff#

A delivery succeeds when your receiver answers any 2xx within 10 seconds. Anything else is a failure: another status, a timeout, a refused connection, a TLS error. Redirects are not followed — a 3xx counts as a failure, so give the endpoint its final address.

A failed delivery is tried again, 7 attempts in all, about 18 hours from first to last:

When each attempt is made
AttemptWhen
1 At once, within seconds of the change
2 1 minute after the first failed
3 5 minutes after that
4 30 minutes after that
5 2 hours after that
6 5 hours after that
7 10 hours after that — the last

After the seventh, the delivery is abandoned. It stays in the log, and the owner can still Redeliver it. Each attempt is re-signed with a fresh t. We read at most the first 4 KB of your answer, for the log, so there is no need to send a body.

Ordering and idempotency#

  • Events can arrive more than once. A retry after a timeout, or a redelivery, carries the same id and the same LineMeUp-Event-Id. Record the ids you have handled and skip one you have seen.
  • Events can arrive out of order. A visit can be called a moment after it is created, and a retried visit.created can arrive after the visit.status_changed that followed it. Each payload carries the whole visit as it was then; keep it only if its updatedAt is newer than the copy you hold.
  • Events can be missed if your receiver is down for longer than the retries last. Read GET /v1/visits with updatedSince now and then to catch up.

Automatic disabling#

An endpoint that keeps failing is switched off by itself, so nobody's receiver is hammered for weeks: once it has had 50 failures in a row and no success for 3 days. A burst of failures in one bad afternoon does not do it, and neither does one failure a day.

When it happens, the endpoint shows as switched off in Settings with the reason, and deliveries still waiting to be retried are abandoned. Fix the receiver, switch it back on — the failure count starts again from nothing — and send a test event to check it. Events that happened while it was off are not sent afterwards; read them with GET /v1/visits.

Deliveries stop the same way when the business's plan stops including webhooks, and start again for new events when it does.

Testing with Send test event#

On any endpoint, Send test event sends a sample of the type you choose — webhook.test if you choose none — to that endpoint alone, signed like any other.

  • It is a sample, with "test": true and made-up ids, line and customer — never one of the business's real customers.
  • It goes only to an endpoint that is switched on.
  • Send test event and Redeliver together can be pressed 30 times an hour for each endpoint; after that they are refused until the hour is up.
  • It is tried once. If your receiver does not answer 2xx, it is failed in the log at once, with the reason — press again after fixing.
A test event
{
  "id": "evt-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "type": "webhook.test",
  "apiVersion": "v1",
  "createdAt": "2026-10-30T05:00:00.000Z",
  "businessId": "bus-0193f2a1-7c4e-7a10-9b2e-000000000001",
  "test": true,
  "data": {
    "message": "A test event from Line Me Up. Your endpoint is receiving webhooks."
  }
}

The delivery log#

Each endpoint has a log of every delivery, newest first, which the owner can filter by status and event type.

A delivery's status
StatusMeans
queued Waiting for its first attempt — usually for a second or two.
retrying An attempt failed and another is scheduled.
delivered Your receiver answered 2xx.
failed A test or a redelivery that did not get a 2xx. Those are tried once.
abandoned Every attempt failed, or delivery stopped: the endpoint was switched off or the plan stopped including webhooks.

Opening a delivery shows:

  • every attempt — when, the status your receiver answered, how long it took, and the start of its answer or what went wrong;
  • the request exactly as sent, headers and body, with the signature's hashes left out — the secret is never stored with it;
  • Redeliver.

Redeliver sends the same event again — the same id, a new LineMeUp-Delivery-Id, a fresh signature — once, as a new row of the log. It is available once a delivery has finished, not while it is still queued or retrying.

Each endpoint keeps at least its last 200 deliveries, and everything from the last 30 days, whichever is more. Older ones are forgotten.