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: 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);@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. Then create and scope a subscription and use the event catalog 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.