← Back to home

Webhooks

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.

Incoming: send events to LetRelay

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.

  • Request: POST with a JSON body of up to 100 KB. A body that isn't JSON is kept as the message.
  • Generic fields: the title comes from title, subject, summary, name or event; the description from body, message, description or text (otherwise the whole JSON).
  • Retries are safe: give each event an 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.

Signing an incoming request

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.

Responses

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

Outgoing: LetRelay calls your endpoint

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.

  • Events: request.created, request.status_changed, request.resolved, request.assigned, request.reply, task.created, task.completed.
  • Generic JSON format: {"event": …, "data": {…}, "sent_at": …}, where data carries the fields of that event (for example ref_code, title, status, priority, and a readable message).
  • Slack and Discord formats get a ready-made message instead, and are not signed: those services don't check signatures.

Verifying an outgoing request

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)

Delivery and retries

  • Answer with any 2xx within 5 seconds; do slow work after replying.
  • Anything else is retried, up to 5 attempts, after about 40 s, 80 s, 160 s and 320 s (with a little jitter; the queue runs every minute). A retry is a fresh request with a new timestamp and sent_at, so if your handler must never act twice, key on event plus data.ref_code.
  • After 15 failed deliveries in a row the webhook switches itself off and its creator is notified. Fix the endpoint and switch it back on.
  • Every delivery is logged in Settings → Webhooks with its status, HTTP code and the start of your response, and an admin can replay any of them.

Questions

Email support@letrelay.com.