Skip to content

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.

This is the default for new endpoints (Sender: Any system, How requests are checked: Signed (HMAC-SHA256)).

  1. Take the current time in unix seconds, for example 1790000000.
  2. Join it, a dot and the raw request body: <timestamp>.<body>.
  3. 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.
  4. Send these headers with the request:
    • X-Liitora-Timestamp: <timestamp>
    • X-Liitora-Signature: sha256=<hex>
  5. Send Content-Type and, if you can, an Idempotency-Key with 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.

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.

#!/usr/bin/env bash
set -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.

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());
import hashlib
import hmac
import json
import os
import time
import urllib.error
import 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())
Terminal window
$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.

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.

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-Delivery header 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/json and paste the endpoint’s signing secret as the Secret.

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>. Several v1 values are allowed. The same clock difference rule applies.
  • Liitora uses the Stripe event id as 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.

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.

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)
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.

  • 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.body and the headers as trigger.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.

Liitora signs its own outgoing webhooks with the same scheme:

  • the webhook.send built-in action with sign: true, using the signing secret of the connection bound to target, 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.

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);
}
import hashlib
import hmac
import 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.