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

# Receiver Example

> Working receiver code for verifying Leen's HMAC-signed webhook deliveries

## Overview

A complete, copy-pasteable receiver for [External Webhooks](/webhooks/external-webhooks). Every
delivery is HMAC-signed, so verification is the same on every endpoint - there is no
unauthenticated mode to handle.

Verification is a handful of standard-library calls in any language. There is no Leen SDK to
install and nothing to import.

<Warning>
  **Verify against the raw request body.** Leen signs the exact bytes it sends. If your framework
  parses the JSON and you re-serialize it before hashing, key order and whitespace change, the
  digest changes, and every signature check fails. Each example below reads the raw body first,
  deliberately.
</Warning>

## The two rules that matter

1. **Compare in constant time.** Use `hmac.compare_digest` (Python), `crypto.timingSafeEqual`
   (Node), or `hmac.Equal` (Go) - never `==`. A plain string comparison returns faster the earlier
   it finds a mismatched byte, which leaks the expected signature a byte at a time to anyone
   willing to measure.
2. **Check the digest before the timestamp.** Then a replayed request is reported as a replay
   rather than as a bad secret, and you can tell the two apart when debugging.

## HMAC

The signature header is `v0=sha256=<hex digest>`, and the signed payload is
`{timestamp}.{raw body}`.

<CodeGroup>
  ```python Python (FastAPI) theme={null}
  import hashlib
  import hmac
  import os
  import time

  from fastapi import FastAPI, Request, Response

  SECRET = os.environ["LEEN_SIGNING_SECRET"]  # returned once when the endpoint was created
  SIGNATURE_VERSION = "v0"
  SIGNATURE_ALGORITHM = "sha256"
  TOLERANCE_SECONDS = 300

  app = FastAPI()


  def verify(signature_header: str, timestamp_header: str, body: bytes) -> tuple[bool, str]:
      # {version}={algorithm}={hex}. Reject anything else rather than guessing, so a
      # future scheme bump fails loudly here instead of silently accepting.
      version, _, remainder = signature_header.partition("=")
      algorithm, _, digest = remainder.partition("=")
      if version != SIGNATURE_VERSION or algorithm != SIGNATURE_ALGORITHM or not digest:
          return False, "malformed signature header"

      try:
          timestamp = int(timestamp_header)
      except ValueError:
          return False, "non-integer timestamp"

      expected = hmac.new(
          SECRET.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
      ).hexdigest()
      if not hmac.compare_digest(digest, expected):
          return False, "signature mismatch"

      # Only after the digest checks out, so a replay reads as a replay.
      if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
          return False, "timestamp outside tolerance"

      return True, "ok"


  @app.post("/leen/webhooks")
  async def receive(request: Request) -> Response:
      body = await request.body()  # raw bytes, before any parsing
      signature = request.headers.get("x-leen-signature", "")
      timestamp = request.headers.get("x-leen-timestamp", "")
      if not signature or not timestamp:
          return Response(status_code=401)

      ok, detail = verify(signature, timestamp, body)
      if not ok:
          print(f"rejected: {detail}")
          return Response(status_code=401)

      event_id = request.headers.get("x-leen-event-id")
      event_type = request.headers.get("x-leen-event-type")
      if already_processed(event_id):        # dedupe: retries reuse this id
          return Response(status_code=200)

      enqueue(event_id, event_type, body)    # hand off, then ack
      return Response(status_code=200)
  ```

  ```javascript Node (Express) theme={null}
  const crypto = require('crypto');
  const express = require('express');

  const SECRET = process.env.LEEN_SIGNING_SECRET;
  const SIGNATURE_VERSION = 'v0';
  const SIGNATURE_ALGORITHM = 'sha256';
  const TOLERANCE_SECONDS = 300;

  const app = express();

  // express.raw, not express.json - the digest covers the bytes as sent.
  app.post('/leen/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
    const signatureHeader = req.get('X-Leen-Signature') || '';
    const timestampHeader = req.get('X-Leen-Timestamp') || '';
    if (!signatureHeader || !timestampHeader) return res.sendStatus(401);

    const [version, algorithm, digest] = signatureHeader.split('=');
    if (version !== SIGNATURE_VERSION || algorithm !== SIGNATURE_ALGORITHM || !digest) {
      return res.sendStatus(401);
    }

    const timestamp = Number(timestampHeader);
    if (!Number.isInteger(timestamp)) return res.sendStatus(401);

    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(`${timestamp}.`)
      .update(req.body)          // raw Buffer
      .digest('hex');

    // timingSafeEqual throws on length mismatch, so guard before calling it.
    const a = Buffer.from(digest, 'utf8');
    const b = Buffer.from(expected, 'utf8');
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

    if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return res.sendStatus(401);

    const eventId = req.get('X-Leen-Event-Id');
    if (alreadyProcessed(eventId)) return res.sendStatus(200);

    enqueue(eventId, req.get('X-Leen-Event-Type'), req.body);
    res.sendStatus(200);
  });
  ```

  ```go Go (net/http) theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"io"
  	"log"
  	"math"
  	"net/http"
  	"os"
  	"strconv"
  	"strings"
  	"time"
  )

  var secret = []byte(os.Getenv("LEEN_SIGNING_SECRET"))

  const (
  	signatureVersion   = "v0"
  	signatureAlgorithm = "sha256"
  	toleranceSeconds   = 300
  )

  func verify(signatureHeader, timestampHeader string, body []byte) (bool, string) {
  	// {version}={algorithm}={hex}. Reject anything else rather than guessing, so a
  	// future scheme bump fails loudly here instead of silently accepting.
  	parts := strings.SplitN(signatureHeader, "=", 3)
  	if len(parts) != 3 || parts[0] != signatureVersion ||
  		parts[1] != signatureAlgorithm || parts[2] == "" {
  		return false, "malformed signature header"
  	}

  	timestamp, err := strconv.ParseInt(timestampHeader, 10, 64)
  	if err != nil {
  		return false, "non-integer timestamp"
  	}

  	mac := hmac.New(sha256.New, secret)
  	mac.Write([]byte(strconv.FormatInt(timestamp, 10) + "."))
  	mac.Write(body) // raw bytes, never a re-encoded struct
  	expected := hex.EncodeToString(mac.Sum(nil))

  	if !hmac.Equal([]byte(parts[2]), []byte(expected)) {
  		return false, "signature mismatch"
  	}

  	// Only after the digest checks out, so a replay reads as a replay.
  	if math.Abs(float64(time.Now().Unix()-timestamp)) > toleranceSeconds {
  		return false, "timestamp outside tolerance"
  	}

  	return true, "ok"
  }

  func receive(w http.ResponseWriter, r *http.Request) {
  	body, err := io.ReadAll(r.Body) // raw bytes, before any parsing
  	if err != nil {
  		w.WriteHeader(http.StatusBadRequest)
  		return
  	}

  	signature := r.Header.Get("X-Leen-Signature")
  	timestamp := r.Header.Get("X-Leen-Timestamp")
  	if signature == "" || timestamp == "" {
  		w.WriteHeader(http.StatusUnauthorized)
  		return
  	}

  	if ok, detail := verify(signature, timestamp, body); !ok {
  		log.Printf("rejected: %s", detail)
  		w.WriteHeader(http.StatusUnauthorized)
  		return
  	}

  	eventID := r.Header.Get("X-Leen-Event-Id")
  	if alreadyProcessed(eventID) { // dedupe: retries reuse this id
  		w.WriteHeader(http.StatusOK)
  		return
  	}

  	enqueue(eventID, r.Header.Get("X-Leen-Event-Type"), body) // hand off, then ack
  	w.WriteHeader(http.StatusOK)
  }

  func main() {
  	http.HandleFunc("/leen/webhooks", receive)
  	log.Fatal(http.ListenAndServe(":9000", nil))
  }
  ```
</CodeGroup>

## Testing it locally

<Steps>
  <Step title="Expose your receiver">
    Leen only delivers to public HTTPS addresses, so a local port needs a tunnel:

    ```bash theme={null}
    ngrok http 9000
    ```
  </Step>

  <Step title="Register the tunnel URL">
    In the portal, go to **Settings → Webhooks → Add Endpoint** and register the tunnel address
    (`https://<subdomain>.ngrok.app/leen/webhooks`), subscribed to at least `webhook.ping`. Full
    walkthrough: [Registering an endpoint](/webhooks/external-webhooks#registering-an-endpoint).

    Copy the signing secret into `LEEN_SIGNING_SECRET` when it is shown - that is the only time you
    will see it.
  </Step>

  <Step title="Ping it">
    Hit **Send test** on the endpoint. That queues a `webhook.ping` to your tunnel.
  </Step>

  <Step title="Confirm what Leen saw">
    Open the endpoint and read the **Deliveries** table. Every attempt records your status code and
    a snippet of your response body, so a rejected signature shows up there as a `401` without you
    adding any logging on your side.
  </Step>
</Steps>

### Exercising the retry path

To confirm your retry handling, return a `500` from the receiver for the first few requests. Leen
makes 5 attempts - roughly 10s, 30s, 90s, then 4.5m apart - and every one carries the same
`X-Leen-Event-Id`, which is exactly the duplicate case your deduplication has to survive.

Returning a `400` or `401` instead ends the delivery immediately with no retry, which is the
behaviour to expect while a signature check is misconfigured.
