Webhook signing
Who this is for: developers who send webhooks to Liitora, or receive signed webhooks from it.
Before you start: you need a webhook endpoint and its signing secret. See Set up a webhook. A person with the Reveal secrets permission shows the secret on the endpoint’s page.
A webhook endpoint is a public URL, https://<your Liitora webhook address>/hooks/<random id>. Anyone on the internet can reach it, so Liitora only accepts a delivery when the sender proves it knows the endpoint’s secret. This page describes exactly how.
The Liitora scheme (any system)
Section titled “The Liitora scheme (any system)”This is the default for new endpoints (Sender: Any system, How requests are checked: Signed (HMAC-SHA256)).
- Take the current time in unix seconds, for example
1790000000. - Join it, a dot and the raw request body:
<timestamp>.<body>. - Compute the HMAC-SHA256 of that text. The key is the signing secret, used as text (its UTF-8 bytes). Write the result as lower-case hex.
- Send these headers with the request:
X-Liitora-Timestamp: <timestamp>X-Liitora-Signature: sha256=<hex>
- Send
Content-Typeand, if you can, anIdempotency-Keywith a unique id per event.
Rules Liitora applies:
| Rule | Detail |
|---|---|
| Clock difference | The timestamp may be at most 300 seconds in the past or the future. This is the Accepted clock difference on the endpoint page (1 to 3600 seconds per endpoint). Keep the sender’s clock in sync. |
| Timestamp format | Digits only: whole seconds, no sign, no milliseconds. |
| What is signed | The body exactly as sent, byte for byte. Sign the bytes you send, not a re-encoded copy of the data. |
| Header once | The timestamp and signature headers must each appear exactly once. |
| Several signatures | The signature header may hold up to 5 signatures separated by commas, for example sha256=<hex>,sha256=<hex>. One must match. This lets a sender sign with both the old and the new secret while you rotate. |
| Letters | sha256 and the hex digits may be upper or lower case. Other algorithms (sha1=, sha512=) are refused. |
| After a rotation | The previous secret keeps working until the grace period ends (24 hours unless you pick another time). See Rotate webhook secrets. |
| Header name | The signature header can be renamed per endpoint through the API (signature_header). The timestamp header is always X-Liitora-Timestamp. |
Code samples
Section titled “Code samples”Each sample signs the same body and sends it. They read the URL and the secret from the environment variables LIITORA_WEBHOOK_URL and LIITORA_WEBHOOK_SECRET, so the secret is never in the code. With the secret example-signing-secret, the timestamp 1790000000 and the body {"event":"order.created","order_id":"1234"}, every sample computes the signature 5b0af085a4997ce2b31b3e7c9236c76bfade8693ce508720cef5b0140db3e469. Use this to check your own code.
bash with curl and openssl
Section titled “bash with curl and openssl”#!/usr/bin/env bashset -euo pipefail: "${LIITORA_WEBHOOK_URL:?set LIITORA_WEBHOOK_URL to the endpoint URL}": "${LIITORA_WEBHOOK_SECRET:?set LIITORA_WEBHOOK_SECRET to the signing secret}"
body='{"event":"order.created","order_id":"1234"}'ts="$(date +%s)"sig="$(printf '%s.%s' "$ts" "$body" \ | openssl dgst -sha256 -hmac "$LIITORA_WEBHOOK_SECRET" -hex \ | sed 's/^.* //')"
curl -sS -X POST "$LIITORA_WEBHOOK_URL" \ -H 'Content-Type: application/json' \ -H "X-Liitora-Timestamp: $ts" \ -H "X-Liitora-Signature: sha256=$sig" \ -H 'Idempotency-Key: order-1234-created' \ --data-binary "$body"Use --data-binary, not -d: -d can change line breaks in the body, and then the signature no longer matches.
Node.js (18 or later)
Section titled “Node.js (18 or later)”import { createHmac } from "node:crypto";
const url = process.env.LIITORA_WEBHOOK_URL;const secret = process.env.LIITORA_WEBHOOK_SECRET;
// Sign the exact text you send. Do not serialize the data again after signing.const body = JSON.stringify({ event: "order.created", order_id: "1234" });const ts = Math.floor(Date.now() / 1000).toString();const sig = createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "X-Liitora-Timestamp": ts, "X-Liitora-Signature": `sha256=${sig}`, "Idempotency-Key": "order-1234-created", }, body,});console.log(res.status, await res.text());Python 3 (standard library)
Section titled “Python 3 (standard library)”import hashlibimport hmacimport jsonimport osimport timeimport urllib.errorimport urllib.request
url = os.environ["LIITORA_WEBHOOK_URL"]secret = os.environ["LIITORA_WEBHOOK_SECRET"]
# Sign the exact bytes you send.body = json.dumps({"event": "order.created", "order_id": "1234"}, separators=(",", ":")).encode()ts = str(int(time.time()))sig = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
req = urllib.request.Request( url, data=body, method="POST", headers={ "Content-Type": "application/json", "X-Liitora-Timestamp": ts, "X-Liitora-Signature": f"sha256={sig}", "Idempotency-Key": "order-1234-created", },)try: with urllib.request.urlopen(req) as res: print(res.status, res.read().decode())except urllib.error.HTTPError as err: print(err.code, err.read().decode())PowerShell
Section titled “PowerShell”$url = $env:LIITORA_WEBHOOK_URL$secret = $env:LIITORA_WEBHOOK_SECRET
$body = '{"event":"order.created","order_id":"1234"}'$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($body)$ts = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()
$hmac = [System.Security.Cryptography.HMACSHA256]::new([System.Text.Encoding]::UTF8.GetBytes($secret))$hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes("$ts.$body"))$sig = -join ($hash | ForEach-Object { $_.ToString('x2') })
Invoke-RestMethod -Method Post -Uri $url -ContentType 'application/json' -Body $bodyBytes -Headers @{ 'X-Liitora-Timestamp' = $ts 'X-Liitora-Signature' = "sha256=$sig" 'Idempotency-Key' = 'order-1234-created'}Send the body as bytes ($bodyBytes), as above. Windows PowerShell 5.1 may change the encoding of a text body, and then the signature no longer matches.
A ready-made test script
Section titled “A ready-made test script”On the endpoint’s page in the app, Send a test delivery and Show the script give you a bash script for that endpoint and its scheme. It reads the secret from LIITORA_WEBHOOK_SECRET. Run it to check that your endpoint and workflow work before you set up the real sender.
GitHub
Section titled “GitHub”Choose Sender: GitHub when you create the endpoint. Liitora then checks GitHub’s own signature:
- GitHub sends
X-Hub-Signature-256: sha256=<hex>, the HMAC-SHA256 of the raw body with the secret as key. There is no timestamp. - Liitora uses GitHub’s
X-GitHub-Deliveryheader as the idempotency key, so a redelivered event starts nothing new. - Because GitHub signs no timestamp, Liitora also remembers a fingerprint of each signed body for as long as it keeps the event (your plan’s retention period). A copy of an earlier delivery is a duplicate, even with a different
X-GitHub-Delivery. - In GitHub, set Content type to
application/jsonand paste the endpoint’s signing secret as the Secret.
Stripe
Section titled “Stripe”Choose Sender: Stripe when you create the endpoint. Liitora then checks Stripe’s own signature:
- Stripe sends
Stripe-Signature: t=<timestamp>,v1=<hex>, the HMAC-SHA256 of<timestamp>.<raw body>. Severalv1values are allowed. The same clock difference rule applies. - Liitora uses the Stripe event
idas the idempotency key, and also a fingerprint of the signed timestamp and body, so a copied delivery is a duplicate whatever its other headers say. - Stripe creates the signing secret, not Liitora. After you create the endpoint, paste Stripe’s secret through the link Liitora shows (Paste Stripe’s signing secret). Until then every delivery is refused.
Bearer token and user name with password
Section titled “Bearer token and user name with password”For senders that cannot sign, choose another check in How requests are checked (only with Sender: Any system):
| Check | The sender sends |
|---|---|
| Bearer token | Authorization: Bearer <signing secret> |
| Username and password | Authorization: Basic <base64 of "<endpoint name>:<signing secret>">. The user name is the endpoint’s name. |
These checks don’t sign the request, so they can’t tell a copied request from a new one. Anyone who copies one request can send it again. Prefer signing when the sender supports it. If you can’t, ask the sender to send an Idempotency-Key header with a unique id per event: deliveries without one are not de-duplicated in these modes. The app says this when you choose one of these checks.
What Liitora accepts
Section titled “What Liitora accepts”| Item | Rule |
|---|---|
| Method | POST or PUT |
| Content types | application/json, application/*+json, application/x-www-form-urlencoded, text/plain, application/xml, text/xml, application/*+xml |
| Size | At most 256 KB, or the endpoint’s own lower limit |
| Idempotency key | Idempotency-Key (1 to 255 visible ASCII characters). Without it: GitHub’s delivery id or the Stripe event id. A repeat of a key with the same body is a duplicate, even when you sign it again with a new timestamp. The same key with a different body is refused with 422, so use a new key for every event |
| Replays | For signed deliveries Liitora always also records a fingerprint of what the signature covers: the timestamp and body, or for GitHub the body. A copy of an earlier delivery is a duplicate whatever its Idempotency-Key, delivery id or Content-Type say |
| Rate | 60 deliveries a minute per endpoint by default, plus a company-wide limit from your plan. Bursts up to the limit are fine |
| Sender addresses | Any, unless the endpoint has an IP allowlist (set through the API) |
The answers
Section titled “The answers”| Status | Body | Meaning |
|---|---|---|
| 202 | {"event_id", "run_ids", "duplicate": false} |
Accepted. run_ids lists the runs it started: one per active workflow that listens on the endpoint. An empty list means nothing listens yet. |
| 200 | {"event_id", "run_ids", "duplicate": true} |
A repeat of an event already received. Nothing new starts. |
| 400 | invalid_body, invalid_idempotency_key |
The body or the key cannot be read. |
| 401 | unauthorized |
Not accepted: unknown or paused URL, sender address not allowed, or a missing or wrong signature. The answer is the same for every cause. |
| 403 | account_restricted |
The company cannot accept webhooks right now. |
| 413 | payload_too_large |
The body is too large. |
| 415 | unsupported_media_type |
The content type is not accepted. |
| 422 | idempotency_key_reused |
The key was already used for a different body. Nothing is recorded and nothing runs. |
| 429 | rate_limited |
Too many deliveries. Retry after Retry-After seconds. |
| 503 | unavailable |
Could not be recorded now. Retry after Retry-After seconds. |
Retry on 429 and 5xx. Do not retry a 401 without fixing the signature first.
What Liitora keeps
Section titled “What Liitora keeps”- The body, the content type and the headers, for the inbound event retention of your plan (14 days by default). Workflows read the body as
trigger.bodyand the headers astrigger.headers. - Never kept:
Authorization, cookies, every signature header, API key and token headers, and headers that show the sender’s network address. They never reach storage, logs or runs. - Recent events on the endpoint page shows the kept headers, the size and the runs of each delivery, never the body.
Check signatures that Liitora sends
Section titled “Check signatures that Liitora sends”Liitora signs its own outgoing webhooks with the same scheme:
- the
webhook.sendbuilt-in action withsign: true, using the signing secret of the connection bound totarget, see webhook.send; - alert messages sent to a webhook channel, using the channel’s signing key, see Set up alerts.
Both send X-Liitora-Timestamp: <unix seconds> and X-Liitora-Signature: sha256=<hex>, the HMAC-SHA256 of <timestamp>.<raw body>. webhook.send also sends an Idempotency-Key that stays the same when the step is retried, so you can ignore repeats. Check the signature on the raw body before you parse it, and compare in constant time.
Node.js
Section titled “Node.js”import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the exact bytes you received (a Buffer). headers: lower-case names.export function verifyLiitoraSignature(rawBody, headers, secret, maxAgeSeconds = 300) { const ts = headers["x-liitora-timestamp"]; const sigHeader = headers["x-liitora-signature"]; if (!/^\d+$/.test(ts ?? "") || !sigHeader?.startsWith("sha256=")) return false; if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > maxAgeSeconds) return false; const expected = createHmac("sha256", secret) .update(Buffer.concat([Buffer.from(`${ts}.`), rawBody])) .digest(); const given = Buffer.from(sigHeader.slice("sha256=".length), "hex"); return given.length === expected.length && timingSafeEqual(given, expected);}Python
Section titled “Python”import hashlibimport hmacimport time
def verify_liitora_signature(raw_body: bytes, headers: dict, secret: str, max_age_seconds: int = 300) -> bool: """raw_body: the exact bytes you received. headers: lower-case names.""" ts = headers.get("x-liitora-timestamp", "") sig_header = headers.get("x-liitora-signature", "") if not ts.isdigit() or not sig_header.startswith("sha256="): return False if abs(int(time.time()) - int(ts)) > max_age_seconds: return False expected = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sig_header[len("sha256="):].lower())Answer with a 2xx status when the check passes. Liitora treats any other answer as a failure: webhook.send fails the step, and alert deliveries are retried up to 5 times on 408, 429, 5xx and network errors.