> ## Documentation Index
> Fetch the complete documentation index at: https://docs.trylath.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Every Lath operation is POST https://platform.trylath.com/<operation name with dots replaced by slashes>, with a JSON body and `Authorization: Bearer <key>`. `email.send` is POST /email/send.
> Branch on `error.code`, never on `error.message`. Every refusal also carries `error.fix`, which names the next step.
> Send an `Idempotency-Key` header on any operation that is not retry-safe, so a retry cannot run it twice.
> A `lath_test_` key emails only the account's own members and sends no SMS; a `lath_live_` key reaches real recipients and is billed.
> The OpenAPI document, generated from the same registry as the routes, is at https://platform.trylath.com/openapi.json.

# Webhooks

> Events arrive signed, with the timestamp inside the signature so a captured request cannot be replayed at you later. Every attempt keeps its own row, so a delivery that failed can be read before it is replayed.

<Steps>
  <Step title="Create an endpoint">
    `developers.webhook.create` returns the signing secret once. Store it before the next call — no operation returns it again, and the way to get a new one is to rotate.

    `developers.webhook.test` sends a delivery to the endpoint on demand, which is how you check the handler before an event depends on it.

    ```bash theme={null}
    lath developers webhook create \
      --url https://example.com/hooks/lath \
      --eventTypes email.sent --eventTypes email.bounced
    ```
  </Step>

  <Step title="Verify the signature">
    Every delivery carries `Lath-Signature: t=<unix seconds>,v1=<hex>`, where `v1` is HMAC-SHA256 over the string `<t>.<raw body>` using the endpoint's secret.

    **Use the raw body.** Parsing the JSON and re-serialising it produces different bytes — a different key order or a different space is a different signature — and the check will fail for reasons that look like nothing. Read the body as text, verify, then parse.

    The timestamp is inside the signed string rather than beside it, so it cannot be swapped for a fresh one. Deliveries more than five minutes old are rejected by default.

    ```ts theme={null}
    import { verifyWebhook } from "@trylath/sdk";

    export async function POST(req: Request) {
      const raw = await req.text();                     // raw, not await req.json()
      if (!verifyWebhook(process.env.LATH_WEBHOOK_SECRET!, req.headers, raw)) {
        return new Response("bad signature", { status: 401 });
      }
      const event = JSON.parse(raw);
      // ... act on event
      return new Response("ok");
    }
    ```
  </Step>

  <Step title="Verify it without the SDK">
    The scheme is deliberately small enough to implement anywhere. Compare in constant time — a byte-by-byte comparison that returns early leaks how much of the signature was right.

    ```ts theme={null}
    import { createHmac, timingSafeEqual } from "node:crypto";

    /** header: the Lath-Signature value. rawBody: the request body exactly as it arrived. */
    export function verifyLathSignature(secret: string, header: string, rawBody: string, toleranceSeconds = 300): boolean {
      const m = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/.exec(header);
      if (!m) return false;
      if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSeconds) return false;

      const expected = createHmac("sha256", secret).update(`${m[1]}.${rawBody}`).digest();
      const given = Buffer.from(m[2], "hex");
      return expected.length === given.length && timingSafeEqual(expected, given);
    }
    ```
  </Step>

  <Step title="Verify it on an edge runtime">
    The version above imports `node:crypto`, which Cloudflare Workers only provide with `nodejs_compat`. This one uses WebCrypto alone, so it runs on Workers, Deno, Bun, Vercel Edge and a browser without a flag.

    `crypto.subtle.verify` does the comparison itself and does it in constant time, so there is no hand-written compare to get wrong. The secret is used exactly as it was issued, prefix included.

    ```text theme={null}
    /** header: the Lath-Signature value. rawBody: the request body exactly as it arrived. */
    export async function verifyLathSignature(secret: string, header: string, rawBody: string, toleranceSeconds = 300): Promise<boolean> {
      const m = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/.exec(header);
      if (!m) return false;
      if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSeconds) return false;

      const enc = new TextEncoder();
      const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
      const signature = new Uint8Array(m[2].match(/../g)!.map((byte) => parseInt(byte, 16)));
      return crypto.subtle.verify("HMAC", key, signature, enc.encode(`${m[1]}.${rawBody}`));
    }
    ```
  </Step>

  <Step title="Rotate the secret without dropping a delivery">
    `developers.webhook.secret.rotate` is there so a leaked secret does not mean a broken endpoint. There is no overlap window: the old secret stops verifying the moment the rotation commits, because deliveries are signed when they are attempted rather than when they are queued.

    So deploy the new secret promptly. Deliveries that fail in the gap are not lost — they retry on the normal schedule and succeed once your receiver has the new secret. The endpoint keeps its id, its URL, its event filter and everything already queued against it.

    ```bash theme={null}
    lath developers webhook secret rotate --endpointId <endpointId>
    ```
  </Step>

  <Step title="When your endpoint is down">
    A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours and 24 hours — seven attempts over about a day and a half. After the last one it is marked `dead` rather than dropped, and it stays readable and replayable.

    Every attempt keeps its own row with its status, error and duration, so a failure can be read rather than guessed at. `developers.webhook.delivery.get` returns one, and `developers.webhook.deliveries.list` filters by status: `queued`, `delivering`, `delivered`, `failed` or `dead`.

    A delivery is replayable once the endpoint is back — one at a time or in bulk — which is the point of storing the attempt rather than only the outcome.

    `developers.webhook.disable` stops deliveries without deleting the endpoint or its history; `enable` resumes.
  </Step>

  <Step title="Answer fast">
    Acknowledge with a 2xx as soon as the signature checks out, and do the work afterwards. An endpoint that finishes its processing before replying is an endpoint that times out under load and then receives the same event again.

    Treat delivery as at-least-once: the same event can arrive twice. Key your handler on the event id so the second copy is a no-op.
  </Step>
</Steps>
