Skip to content

Verify webhooks with the SDK

Verify SeatLayer webhook signatures against the raw body in constant time, and deduplicate deliveries so a replay cannot double-process an order.

Updated View as Markdown

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

Expressts
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);
});
Flaskpython
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
Plain PHPphp
use SeatLayer\Webhook;
use SeatLayer\WebhookVerificationException;

// Laravel: $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);
Springjava
@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();
}
net/httpgo
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)
}
Railsruby
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
ASP.NET Corecsharp
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();
});

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

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 for the underlying scheme, and delivery and retries for our retry behaviour.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close