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
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);
});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 "", 200use 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);@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();
}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)
}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
endapp.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.