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

# Verifying webhooks

> Confirm that a webhook request genuinely came from Formable.

Every webhook request includes a signature so you can verify it was sent by Formable and wasn't tampered with in transit. Always verify the signature before acting on a webhook.

## How HMAC validation works

Anyone who learns your webhook URL could send a fake `POST` to it. HMAC Signature verification stops that: Formable and your server share a secret that only the two of you know. On every delivery, Formable uses that secret to stamp the request body, and you check the stamp before trusting the event.

Think of it like a wax seal on a letter:

1. Formable takes the exact request body and your signing secret, and runs them through a one-way function (HMAC-SHA256). The result is a short fingerprint.
2. That fingerprint is sent in the `Content-Sha256` header.
3. Your server repeats the same calculation with the same secret and the raw body you received.
4. If your fingerprint matches the header, the body came from Formable and wasn't altered. If it doesn't match, reject the request.

An attacker without the secret can't forge a matching fingerprint. Changing even one character of the body produces a different fingerprint, so tampering is caught too.

Use the signing secret Formable showed once when you [registered the webhook](/developer-account#register-a-webhook). Store it as an environment secret and use that same value to verify every request.

<Info>
  Your signing secret is provided as a **base64-encoded string**. Decode it to raw bytes and use those bytes as the HMAC key.
</Info>

If you misplaced the secret or don't remember it, Formable cannot show it again. Rotate it from **Settings → Webhooks → Rotate secret**, copy the new secret when it's shown, and update your environment. The previous secret stops working immediately.

To verify a request:

<Steps>
  <Step title="Read the raw body">
    Capture the request body exactly as received, before any JSON parsing or re-serialization. Re-serializing can change whitespace or key order and break verification.
  </Step>

  <Step title="Compute the expected signature">
    HMAC-SHA256 the raw body using the base64-decoded secret as the key, then base64-encode the digest.
  </Step>

  <Step title="Compare">
    Compare your computed value with the `Content-Sha256` header using a constant-time comparison. If they match, the request is authentic.
  </Step>
</Steps>

## Examples

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  import express from "express";
  import crypto from "crypto";

  const app = express();

  // Capture the raw body so the signature is computed over the exact bytes.
  app.use("/webhooks/formable", express.raw({ type: "application/json" }));

  app.post("/webhooks/formable", (req, res) => {
    const received = req.header("Content-Sha256");
    const secret = Buffer.from(process.env.FORMABLE_WEBHOOK_SECRET, "base64");

    const expected = crypto
      .createHmac("sha256", secret)
      .update(req.body) // req.body is a Buffer of the raw bytes
      .digest("base64");

    const valid =
      received &&
      expected.length === received.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));

    if (!valid) {
      return res.status(401).send("Invalid signature");
    }

    const event = JSON.parse(req.body.toString("utf8"));
    // handle event...
    res.sendStatus(200);
  });
  ```

  ```python Python (Flask) theme={null}
  import base64
  import hashlib
  import hmac
  import os

  from flask import Flask, request, abort

  app = Flask(__name__)

  @app.post("/webhooks/formable")
  def formable_webhook():
      received = request.headers.get("Content-Sha256", "")
      secret = base64.b64decode(os.environ["FORMABLE_WEBHOOK_SECRET"])

      # request.get_data() returns the exact raw bytes of the body.
      digest = hmac.new(secret, request.get_data(), hashlib.sha256).digest()
      expected = base64.b64encode(digest).decode()

      if not hmac.compare_digest(expected, received):
          abort(401)

      event = request.get_json()
      # handle event...
      return "", 200
  ```

  ```php PHP theme={null}
  $rawBody = file_get_contents('php://input');
  $headers = getallheaders();
  $received = $headers['Content-Sha256'] ?? '';
  $secret = base64_decode(getenv('FORMABLE_WEBHOOK_SECRET'), true);

  $expected = base64_encode(hash_hmac('sha256', $rawBody, $secret, true));

  if (!hash_equals($expected, $received)) {
      http_response_code(401);
      exit('Invalid signature');
  }

  $event = json_decode($rawBody, true);
  // handle event...
  http_response_code(200);
  ```
</CodeGroup>

## Common pitfalls

<AccordionGroup>
  <Accordion title="Using the parsed body instead of the raw body" icon="triangle-exclamation">
    Frameworks that auto-parse JSON discard the original bytes. Re-serializing the parsed object almost always produces a slightly different string (whitespace, key order), so the HMAC won't match. Always sign over the raw request body.
  </Accordion>

  <Accordion title="Forgetting to base64-decode the secret" icon="key">
    The secret is a base64 string. Use its decoded bytes as the HMAC key — don't pass the base64 string directly.
  </Accordion>

  <Accordion title="Non-constant-time comparison" icon="clock">
    Compare signatures with a constant-time function (`crypto.timingSafeEqual`, `hmac.compare_digest`, `hash_equals`) to avoid timing attacks.
  </Accordion>
</AccordionGroup>

<Warning>
  Keep your signing secret confidential. If it is exposed, misplaced, or forgotten, rotate it from **Settings → Webhooks → Rotate secret** and update your verifier right away.
</Warning>
