---
title: "Verify webhooks with the SDK"
description: "Verify SeatLayer webhook signatures against the raw body in constant time, and deduplicate deliveries so a replay cannot double-process an order."
---

Webhook verification is the most security-sensitive code in a typical
integration, and the two classic mistakes are both silent. The SDKs do it for
you.

## Verify

<Tabs>
<TabItem label="Node.js">

```ts title="Express"
import express from 'express';
import { verifyWebhook, WebhookVerificationError } from '@seatlayer/server';

app.post('/webhooks/seatlayer', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = verifyWebhook({
      payload: req.body,                              // Buffer, not req.body parsed
      signature: req.header('X-SeatLayer-Signature'),
      secret: process.env.SEATLAYER_WEBHOOK_SECRET!,
    });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return res.sendStatus(400);
    throw error;
  }

  if (await alreadyProcessed(event.occurrenceId)) return res.sendStatus(200);
  await handle(event);
  res.sendStatus(200);
});
```

</TabItem>
<TabItem label="Python">

```python title="Flask"
from flask import request
from seatlayer import verify_webhook, WebhookVerificationError

@app.post("/webhooks/seatlayer")
def seatlayer_webhook():
    try:
        event = verify_webhook(
            request.get_data(),                          # raw bytes, not request.json
            request.headers.get("X-SeatLayer-Signature"),
            os.environ["SEATLAYER_WEBHOOK_SECRET"],
        )
    except WebhookVerificationError:
        return "", 400

    if already_processed(event["occurrenceId"]):
        return "", 200

    handle(event)
    return "", 200
```

</TabItem>
<TabItem label="PHP">

```php title="Plain PHP"
use SeatLayer\Webhook;
use SeatLayer\WebhookVerificationException;

// Laravel: use $request->getContent(), never $request->all()
$payload = file_get_contents('php://input');

try {
    $event = Webhook::verify(
        $payload,
        $_SERVER['HTTP_X_SEATLAYER_SIGNATURE'] ?? null,
        getenv('SEATLAYER_WEBHOOK_SECRET'),
    );
} catch (WebhookVerificationException) {
    http_response_code(400);
    return;
}

if (alreadyProcessed($event['occurrenceId'])) {
    http_response_code(200);
    return;
}

handle($event);
http_response_code(200);
```

</TabItem>
<TabItem label="Java">

```java title="Spring"
@PostMapping("/webhooks/seatlayer")
public ResponseEntity<Void> handle(
        @RequestBody byte[] payload,                       // byte[], never a parsed object
        @RequestHeader("X-SeatLayer-Signature") String signature) {

    Map<String, Object> event;
    try {
        event = Webhook.verify(payload, signature, System.getenv("SEATLAYER_WEBHOOK_SECRET"));
    } catch (WebhookVerificationException e) {
        return ResponseEntity.badRequest().build();
    }

    if (alreadyProcessed((String) event.get("occurrenceId"))) {
        return ResponseEntity.ok().build();
    }

    process(event);
    return ResponseEntity.ok().build();
}
```

</TabItem>
<TabItem label="Go">

```go title="net/http"
func handleWebhook(w http.ResponseWriter, r *http.Request) {
    payload, err := io.ReadAll(r.Body)   // raw bytes, before any decoding
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    event, err := seatlayer.VerifyWebhook(
        payload,
        r.Header.Get("X-SeatLayer-Signature"),
        os.Getenv("SEATLAYER_WEBHOOK_SECRET"),
    )
    if errors.Is(err, seatlayer.ErrWebhookVerification) {
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    if alreadyProcessed(event["occurrenceId"].(string)) {
        w.WriteHeader(http.StatusOK)
        return
    }

    process(event)
    w.WriteHeader(http.StatusOK)
}
```

</TabItem>
<TabItem label="Ruby">

```ruby title="Rails"
def seatlayer
  event = SeatLayer::Webhook.verify(
    request.raw_post,                          # raw body, never params
    request.headers["X-SeatLayer-Signature"],
    ENV.fetch("SEATLAYER_WEBHOOK_SECRET")
  )

  return head :ok if already_processed?(event["occurrenceId"])

  Handler.call(event)
  head :ok
rescue SeatLayer::WebhookVerificationError
  head :bad_request
end
```

</TabItem>
<TabItem label=".NET">

```csharp title="ASP.NET Core"
app.MapPost("/webhooks/seatlayer", async (HttpRequest request) =>
{
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);          // raw bytes, never a bound model

    IReadOnlyDictionary<string, object?> seatEvent;
    try
    {
        seatEvent = Webhook.Verify(
            buffer.ToArray(),
            request.Headers["X-SeatLayer-Signature"],
            Environment.GetEnvironmentVariable("SEATLAYER_WEBHOOK_SECRET")!);
    }
    catch (SeatLayerWebhookVerificationException)
    {
        return Results.BadRequest();
    }

    if (await AlreadyProcessedAsync((string)seatEvent["occurrenceId"]!))
    {
        return Results.Ok();
    }

    await ProcessAsync(seatEvent);
    return Results.Ok();
});
```

</TabItem>
</Tabs>

## The two mistakes this avoids

**Verifying a re-serialised body.** Signatures cover the exact bytes we sent.
Parsing the JSON and re-encoding it (`JSON.stringify(req.body)`,
`json.dumps(request.json)`) reorders keys and changes whitespace, so
verification fails, and the usual "fix" is to disable verification. Always hand
the SDK the raw body: `express.raw()`, `request.get_data()`, `request.body` in
Django, `await request.body()` in FastAPI.

**Comparing signatures with `===`.** A short-circuiting comparison leaks the
expected value through timing. The SDKs use a constant-time compare and handle a
length mismatch without leaking which failure occurred.

## Replay protection is yours

<Aside type="caution" title="Deduplicate on occurrenceId">
  Deliveries are signed over the body, which includes an `at` timestamp, but
  nothing enforces a freshness window, so a captured delivery stays valid
  indefinitely. Anyone who can read one delivery (a log aggregator, a
  misconfigured proxy) can replay it later and it will verify, because it is
  genuinely signed by us.

  Every event carries an `occurrenceId`. Record the ones you have processed and
  ignore repeats. This is your replay protection, not an optimisation.
</Aside>

Deduplication also covers the honest cases: we retry failed deliveries, so the
same `occurrenceId` legitimately arrives more than once whenever your endpoint
was briefly down.

## What a failure means

`WebhookVerificationError` means "this did not come from SeatLayer". Respond
`400` without processing it. Do not log the raw body of an unverified
request. You have no reason to trust its contents or its size.

See [webhook signatures](/webhooks/signatures/) for the underlying scheme, and
[delivery and retries](/webhooks/delivery-and-retries/) for our retry behaviour.
Then [create and scope a subscription](/webhooks/manage-subscriptions/) and use
the [event catalog](/webhooks/events/) to implement only the branches you need.

Webhooks reconcile inventory; they do not replace synchronous command results.
For an Event, the booking response or exact Booking History lookup is the
checkout outcome. For a Performance Group or Season, poll any accepted booking
to its documented terminal state. Never wait for a webhook before deciding
whether to charge, and never charge again merely because a booked occurrence
arrives. The current registry declares no Performance Group-specific webhook;
use the terminal group booking result and underlying Event reconciliation.