LetRelay speaks webhooks both ways: outside systems can post events that become requests or tasks, and LetRelay can post every change to your own endpoint. Both directions can be signed with HMAC-SHA256. This page is the whole contract.
An admin creates an endpoint in Settings → Webhooks → Incoming. Each one has its own secret URL, https://www.letrelay.com/api/hooks/in/<token>, a preset (Generic JSON, GitHub, Web form or Monitoring alert) and an optional signing secret. Anyone who knows the URL can post to it, so set a secret.
POST with a JSON body of up to 100 KB. A body that isn't JSON is kept as the message.title, subject, summary, name or event; the description from body, message, description or text (otherwise the whole JSON).id (or event_id, or an X-Relay-Delivery header). The same id on the same endpoint is filed once; repeats get {"ok": true, "duplicate": true}. GitHub's X-GitHub-Delivery is used automatically.When the endpoint has a secret, send the hex HMAC-SHA256 of the raw request body, keyed with the secret, in X-Relay-Signature. The sha256= prefix is optional, and X-Hub-Signature-256 or X-Signature-256 work too, so GitHub's own signature is accepted as it is: paste the same secret into GitHub. The check is constant-time; anything else gets a 401.
import { createHmac } from "node:crypto";
const url = "https://www.letrelay.com/api/hooks/in/YOUR_TOKEN";
const secret = process.env.LETRELAY_WEBHOOK_SECRET;
const body = JSON.stringify({
id: "evt_123", // makes retries safe: the same id is never filed twice
title: "Server api-2 is down",
body: "Health check failing since 13:02 UTC",
});
const signature = createHmac("sha256", secret).update(body).digest("hex");
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Relay-Signature": `sha256=${signature}` },
body, // send exactly the string you signed
});Or from a shell:
BODY='{"id":"evt_123","title":"Server api-2 is down","body":"Health check failing since 13:02 UTC"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$LETRELAY_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST "https://www.letrelay.com/api/hooks/in/YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Relay-Signature: sha256=$SIG" \
--data "$BODY"Test vector: secret whsec_example_do_not_use and body {"id":"evt_123","title":"Server api-2 is down","body":"Health check failing since 13:02 UTC"} give 4355de52ef97fc60b00a3a34635f1e83f0025a5e055353060463ceaff4df6b8b.
201 {"ok": true, "ref_code": "RLY-1042"}: request created (task endpoints return task_id).200 {"ok": true, "duplicate": true}: that id was already filed. {"ignored": true} for pings.401 invalid or missing signature · 404 unknown or disabled endpoint · 413 body over 100 KB · 500 nothing was created, safe to retry.In Settings → Webhooks → Outgoing, add a URL, pick a format (Slack, Discord or Generic JSON) and the events you want. The Test button sends a test event.
request.created, request.status_changed, request.resolved, request.assigned, request.reply, task.created, task.completed.{"event": …, "data": {…}, "sent_at": …}, where data carries the fields of that event (for example ref_code, title, status, priority, and a readable message).With a signing secret on a Generic JSON webhook, every request carries two headers:
X-Relay-Timestamp: Unix time in seconds when it was sent.X-Relay-Signature: v1, followed by the hex HMAC-SHA256, keyed with your secret, of <timestamp>.<raw body> (the timestamp, a dot, then the body exactly as received).Verify the raw bytes before parsing the JSON (re-serializing changes them), compare in constant time, and reject timestamps older than a few minutes so a captured request can't be replayed.
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-Relay-Timestamp: 1790000000
X-Relay-Signature: v1,dc5c37c465b9f57b8f2ed707e8f54e418a37280c6ef718b14a494b3654004543
{"data": {"title": "Printer on floor 3 is jammed", "ref_code": "RLY-1042"}, "event": "request.created", "sent_at": "2026-09-25T13:00:00+00:00"}That example is signed with the secret whsec_example_do_not_use: your code should accept it (ignoring the timestamp's age) and reject it if one byte of the body changes.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a string), BEFORE any JSON.parse.
export function verifyLetRelay(rawBody, timestamp, signature, secret) {
if (!timestamp || !signature?.startsWith("v1,")) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false; // older than 5 minutes: replay
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
const given = Buffer.from(signature.slice(3), "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
// e.g. in a Next.js route handler:
export async function POST(req) {
const raw = await req.text();
const ok = verifyLetRelay(raw, req.headers.get("x-relay-timestamp"), req.headers.get("x-relay-signature"), process.env.LETRELAY_SECRET);
if (!ok) return new Response("bad signature", { status: 401 });
const { event, data } = JSON.parse(raw);
// ...queue the work, then answer fast
return new Response(null, { status: 204 });
}import hmac, hashlib, time
def verify_letrelay(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
if not timestamp or not signature or not signature.startswith("v1,"):
return False
try:
if abs(time.time() - int(timestamp)) > 300: # older than 5 minutes: replay
return False
except ValueError:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature[3:])
# Flask: verify_letrelay(request.get_data(), request.headers.get("X-Relay-Timestamp"),
# request.headers.get("X-Relay-Signature"), SECRET)2xx within 5 seconds; do slow work after replying.sent_at, so if your handler must never act twice, key on event plus data.ref_code.Email support@letrelay.com.