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: 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);
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. 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close