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

# Verify Edplay Webhook Signatures

> Confirm that a webhook request genuinely came from Edplay by validating the Signature header against your workspace signing secret.

Anyone who learns your endpoint URL can send a `POST` to it. The `Signature` header is how you prove a request actually came from Edplay.

<Warning>
  Treat signature verification as mandatory. An endpoint that accepts unsigned requests will happily record training completions invented by anyone on the internet.
</Warning>

## How the signature is calculated

Edplay computes the signature as a hex-encoded HMAC-SHA256 of the raw request body, keyed with your workspace signing secret:

```
hex( HMAC-SHA256( raw_request_body, your_signing_secret ) )
```

To verify, compute the same value over the body you received and compare it to the `Signature` header.

<Note>
  **The single most important detail:** hash the **raw body bytes exactly as received**. If you parse the JSON and re-serialize it, key order and whitespace shift, the hash changes, and every verification fails. Capture the raw body before any JSON middleware touches it.
</Note>

The `Timestamp` header is not part of the signature, so do not include it in the hash.

## Examples

Each example verifies the signature, hands the payload to a queue, and returns immediately. See [Deliveries and retries](/webhooks/deliveries) for why that ordering matters.

<CodeGroup>
  ```js Node.js (Express) theme={null}
  const crypto = require('crypto');
  const express = require('express');

  const app = express();
  const SECRET = process.env.EDPLAY_WEBHOOK_SECRET;

  // express.raw keeps the body as bytes — express.json() would destroy it
  app.post('/webhooks/edplay', express.raw({ type: 'application/json' }), (req, res) => {
    const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
    const received = req.get('Signature') || '';

    const expectedBuffer = Buffer.from(expected, 'utf8');
    const receivedBuffer = Buffer.from(received, 'utf8');

    if (
      expectedBuffer.length !== receivedBuffer.length ||
      !crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
    ) {
      return res.status(401).send('Invalid signature');
    }

    const payload = JSON.parse(req.body.toString('utf8'));
    enqueue(payload);        // your own queue
    res.sendStatus(200);     // answer immediately
  });
  ```

  ```php PHP (Laravel) theme={null}
  use Illuminate\Http\Request;

  public function handle(Request $request)
  {
      $expected = hash_hmac(
          'sha256',
          $request->getContent(),
          config('services.edplay.webhook_secret')
      );

      if (! hash_equals($expected, (string) $request->header('Signature'))) {
          abort(401, 'Invalid signature');
      }

      ProcessEdplayWebhook::dispatch($request->json()->all());

      return response()->noContent();
  }
  ```

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

  from flask import Flask, request, abort

  app = Flask(__name__)
  SECRET = os.environ["EDPLAY_WEBHOOK_SECRET"].encode()

  @app.post("/webhooks/edplay")
  def edplay_webhook():
      expected = hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
      received = request.headers.get("Signature", "")

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

      enqueue(request.get_json())   # your own queue
      return "", 200
  ```
</CodeGroup>

### Getting the raw body in each framework

| Framework | Raw body                                    | Do not use for the hash                                               |
| --------- | ------------------------------------------- | --------------------------------------------------------------------- |
| Express   | `express.raw()` middleware, then `req.body` | `express.json()`, which replaces the buffer with a parsed object      |
| Laravel   | `$request->getContent()`                    | `$request->all()` or `$request->json()->all()`                        |
| Flask     | `request.get_data()`                        | `request.get_json()`, which is safe only *after* the hash is computed |

## Always compare in constant time

Use `crypto.timingSafeEqual`, `hash_equals`, or `hmac.compare_digest`. A plain `==` on the two strings leaks timing information that can, in principle, let an attacker recover a valid signature byte by byte.

## If verification fails

Return `401` and do not process the payload. The delivery is recorded as **Failed** in the delivery log along with the response body your endpoint returned, which makes a signature rejection easy to tell apart from an application error.

<Tip>
  If your signature never matches, the cause is almost always a re-serialized body or a stale secret after a rotation. See [Troubleshooting](/webhooks/troubleshooting).
</Tip>

Next: [Deliveries and retries](/webhooks/deliveries)
