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:
| Field | Is |
|---|---|
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:
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'sid, the same on every retry.-
LineMeUp-Event-Type— itstype, 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#
- The owner opens Settings → Integrations and, under Webhooks, chooses Add endpoint.
- They enter the URL and, if they like, a description — "Clinic CRM, production" — and choose its events and lines (next section).
-
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. - 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.
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.pausedandline.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#
| Type | Sent 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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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 -
Read
t, the time we signed, in unix seconds, and everyv1from theLineMeUp-Signatureheader. -
Refuse the delivery if
tis more than five minutes from your clock — that stops somebody replaying an old one. -
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 ) ) -
Accept the delivery if any
v1equals it, compared in constant time. There is onev1normally 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 — 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 — 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 — 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.
<?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 — 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.
| Vector | Should |
|---|---|
| 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 |
[
{
"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:
| Attempt | When |
|---|---|
| 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
idand the sameLineMeUp-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.createdcan arrive after thevisit.status_changedthat followed it. Each payload carries the whole visit as it was then; keep it only if itsupdatedAtis newer than the copy you hold. - Events can be missed if your receiver is down for longer than the retries last.
Read
GET /v1/visitswithupdatedSincenow 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": trueand 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 isfailedin the log at once, with the reason — press again after fixing.
{
"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.
| Status | Means |
|---|---|
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.